Skip to main content

ns-ui

Keymap ASCII Heat

An ASCII keyboard layout that accumulates real ink density per key as you type into it: each keystroke inks the key, heat decays on an exponential half-life, and the legend rescales to whichever key is currently hottest.

Usage

registry/core/keymap-ascii-heat/demo.tsx
"use client";

import { KeymapAsciiHeat } from "./component";

export default function KeymapAsciiHeatDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / keymap-ascii-heat</p>
      <KeymapAsciiHeat />
      <p className="max-w-md text-center text-xs text-ns-muted">
        Type into the field — each keystroke inks its key, heat decays on a half-life, and the legend
        rescales to whichever key is currently hottest.
      </p>
    </div>
  );
}

Component

registry/core/keymap-ascii-heat/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// KeymapAsciiHeat — an ASCII keyboard layout that accumulates real ink
// density per key as you type into a real text input: every keydown adds
// heat to that key's cell, heat decays on an exponential half-life so a key
// you stop pressing visibly fades back to blank paper, and the legend's
// scale is recomputed against whichever key is currently hottest. Distinct
// from shortcuts-cheat-sheet (a static keycap depresses itself for a listed
// COMBINATION and swallows the keydown — a rehearsal aid, no accumulation,
// no decay, no legend) and from heatmap-year-stipple (density via jittered
// dot count, not a character ramp, and driven by a canned yearly dataset,
// not live keystrokes). The ramp (" .:-=+*#%@") still names each of the 10
// heat steps in the legend, but a key's OWN cell never draws that ramp
// character behind its letter — two glyphs sharing one small cell just
// overlaid into an illegible mess (a real bug this component shipped with:
// hammer A/S/D/F and the ramp character stacks on the letterform itself).
// Heat instead reads as a flat --foreground fill behind the letter, opacity
// scaled 0 -> ~0.55 by the key's decayed ink over the live max, so every key
// shows exactly one legible glyph at every heat level, in both themes. No
// canvas: every cell is real DOM, colored from --foreground via a Tailwind
// opacity utility, no hex.
// ---------------------------------------------------------------------------

const RAMP = [" ", ".", ":", "-", "=", "+", "*", "#", "%", "@"];
const HALF_LIFE_MS = 7000;
const REPAINT_MS = 90;
const SLEEP_EPS = 0.02;

const ROWS: string[][] = [
  ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
  ["A", "S", "D", "F", "G", "H", "J", "K", "L"],
  ["Z", "X", "C", "V", "B", "N", "M"],
];

function decay(ink: number, dtMs: number): number {
  return ink * Math.pow(0.5, dtMs / HALF_LIFE_MS);
}

function keyFor(e: { key: string }): string | null {
  if (e.key === " ") return "SPACE";
  if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) return e.key.toUpperCase();
  return null;
}

export interface KeymapAsciiHeatProps {
  /** placeholder text for the typing input */
  placeholder?: string;
  /** accessible name for the typing input */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function KeymapAsciiHeat({
  placeholder = "Type here — the keys below heat up as you go",
  label = "Type to build heat",
  className = "",
}: KeymapAsciiHeatProps) {
  const inkRef = useRef(new Map<string, { ink: number; lastAt: number }>());
  const rafRef = useRef(0);
  const lastRepaintRef = useRef(0);
  const reducedRef = useRef(false);

  const [levels, setLevels] = useState<Record<string, number>>({});
  const [maxInk, setMaxInk] = useState(0);
  const [hoveredKey, setHoveredKey] = useState<string | null>(null);
  const [text, setText] = useState("");

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => {
      reducedRef.current = mq.matches;
    };
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  const recompute = (now: number) => {
    const raw: Record<string, number> = {};
    let max = 0;
    let anyLive = false;
    inkRef.current.forEach((v, key) => {
      const cur = decay(v.ink, now - v.lastAt);
      if (cur > SLEEP_EPS) anyLive = true;
      raw[key] = cur;
      if (cur > max) max = cur;
    });
    setLevels(raw);
    setMaxInk(max);
    return anyLive;
  };

  const stop = () => {
    cancelAnimationFrame(rafRef.current);
    rafRef.current = 0;
  };

  const step = (now: number) => {
    rafRef.current = 0;
    if (now - lastRepaintRef.current >= REPAINT_MS) {
      lastRepaintRef.current = now;
      const anyLive = recompute(now);
      if (!anyLive) return; // sleep — pulse() wakes it again
    }
    rafRef.current = requestAnimationFrame(step);
  };

  const wake = () => {
    if (!rafRef.current) rafRef.current = requestAnimationFrame(step);
  };

  useEffect(() => stop, []);

  const pulse = (key: string) => {
    const now = performance.now();
    const m = inkRef.current;
    const prev = m.get(key);
    const cur = prev ? decay(prev.ink, now - prev.lastAt) : 0;
    m.set(key, { ink: cur + 1, lastAt: now });
    if (reducedRef.current) {
      lastRepaintRef.current = now;
      recompute(now);
    } else {
      wake();
    }
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    const key = keyFor(e);
    if (key) pulse(key);
  };

  const MAX_FILL_OPACITY = 0.55;

  const heatOpacityFor = (key: string): number => {
    const v = levels[key] ?? 0;
    if (maxInk <= SLEEP_EPS) return 0;
    return Math.min(1, v / maxInk) * MAX_FILL_OPACITY;
  };

  const legend = useMemo(() => RAMP.join(""), []);

  const renderKey = (key: string, wide = false) => (
    <div
      key={key}
      aria-hidden
      data-key={key}
      onPointerEnter={() => setHoveredKey(key)}
      onPointerLeave={() => setHoveredKey((h) => (h === key ? null : h))}
      className={`relative flex h-9 items-center justify-center rounded-sm border border-border bg-background text-foreground transition-colors duration-100 motion-reduce:transition-none hover:bg-foreground/[0.06] ${
        wide ? "w-40" : "w-9"
      }`}
    >
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 rounded-sm bg-foreground"
        style={{ opacity: heatOpacityFor(key) }}
      />
      <span className="relative z-10 select-none text-[11px] font-semibold tracking-wide">
        {key === "SPACE" ? "␣" : key}
      </span>
    </div>
  );

  return (
    <div className={`inline-flex flex-col gap-3 font-mono ${className}`}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={onKeyDown}
        placeholder={placeholder}
        aria-label={label}
        className="w-full rounded-sm border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors duration-100 motion-reduce:transition-none placeholder:text-ns-muted focus-visible:ring-2 focus-visible:ring-ns-accent"
      />

      <div className="flex flex-col items-center gap-1.5 rounded-sm border border-border bg-surface p-3">
        {ROWS.map((row, i) => (
          <div key={i} className="flex gap-1.5">
            {row.map((k) => renderKey(k))}
          </div>
        ))}
        <div className="flex gap-1.5 pt-0.5">{renderKey("SPACE", true)}</div>
      </div>

      <div className="flex items-center justify-between gap-3 rounded-sm border border-border bg-background px-3 py-1.5 text-[11px] text-ns-muted">
        <span data-legend className="tabular-nums">
          ink {legend} max={maxInk.toFixed(1)}
        </span>
        <span data-hover-readout className="tabular-nums">
          {hoveredKey ? `${hoveredKey === "SPACE" ? "␣" : hoveredKey}: ${(levels[hoveredKey] ?? 0).toFixed(1)}` : "hover a key"}
        </span>
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/keymap-ascii-heat.tsx, so point the import there.

Use when showing which keys are actually getting hammered in a live typing session, driven entirely by real keystrokes rather than a canned dataset — pick shortcuts-cheat-sheet instead for a reference overlay whose keycaps depress themselves for a listed shortcut COMBINATION and swallow the keydown, which has no accumulation, decay or legend at all; pick heatmap-year-stipple instead for a calendar of past activity rather than a live session.

Install

npx shadcn add https://design.helpmarq.com/r/keymap-ascii-heat.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <KeymapAsciiHeat placeholder label className> around a real, visible <input type=text> that the user actually types into (aria-label from the `label` prop). MECHANISM: the keydown handler lives directly on the input's own onKeyDown (never a document-level listener gated on document.activeElement — an autoplay-driven demo runs inside an inert subtree where focus never truly lands, so a focus-gated listener would leave the card dead; binding straight to the input's React event fires regardless). Every keydown whose key resolves to a letter or Space maps to a key id (A-Z, or 'SPACE') and calls pulse(key): a Map<string,{ink,lastAt}> in a ref holds each key's raw ink and the timestamp it was last touched; pulse reads the PREVIOUS entry, decays it forward to now via ink * 0.5^(dt/7000) (7s half-life), adds 1, and stores the new {ink,lastAt} — so heat is a pure function of elapsed time, never a per-frame accumulator, and 'what is this key's ink right now' can be asked at any instant without having run every frame in between. A throttled rAF loop (repaints at most every ~90ms) recomputes every key's CURRENT decayed value from its stored {ink,lastAt} and the loop's own `now`, finds the live max across all keys, and stores both in React state; the loop sleeps (cancels itself) once every key's decayed value has fallen under a small epsilon, and pulse() wakes it again on the next keystroke — so there is no animation running while nobody is typing. RENDERING: each key is a fixed-size cell showing its letter in the foreground, always as the ONLY glyph in the cell — heat never draws a second character on top of the letter (that overlay was a real bug: hammering A/S/D/F used to stack a heavy ramp glyph directly over the letterform and render it illegible). Instead each cell has an absolutely-positioned fill behind the letter, a plain --foreground rect whose opacity is that key's current decayed ink divided by the LIVE max across all keys (0 at rest, up to ~0.55 at the hottest key), so heat reads as density/opacity, not as stacked ink. A legend line beneath the keyboard still prints the 10-step ASCII ramp (' .:-=+*#%@') alongside 'max=<current max ink, one decimal>' as a scale reference, and a second readout beside it names whichever key is currently hovered along with its live decayed ink value, updating on pointerenter/leave — since the keyboard is otherwise a static picture at rest, this hover readout is what makes hover state visibly differ from resting. REDUCED MOTION: skip the rAF loop outright — pulse() still runs the exact same decay math and still updates state, it just does so synchronously inside the keydown handler instead of on a subsequent repaint tick, so a key's ink is still correct at every keystroke, there is simply no continuously-fading glyph between keystrokes. A11Y: the input is the only real control and carries the accessible name; every key cell is a plain aria-hidden decorative div (not a button, not tabbable) since it represents live derived state rather than something to activate — Tab reaches the input, which is the control the 'Tab must reach something' rule cares about. No gate: heat can only be produced by real keystrokes, which the verify gate's single-click model cannot simulate — the `autoplay: type` descriptor is what exercises this component's characteristic non-resting state instead, and its screenshot is what an owner should look at, not a synthetic gate click. Colors are token-only: --foreground at full and reduced opacity for the glyph/letter, --border/--background/--surface for the chrome, --ns-accent only on the input's focus ring. No canvas, zero dependencies.

Props

PropTypeDefaultDescription
placeholder?string"Type here — the keys below heat up as you go"placeholder text for the typing input
label?string"Type to build heat"accessible name for the typing input
className?string—extra classes merged onto the rendered root element