Keymap ASCII Heat

Keyboard

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.

Install
npx shadcn add https://design.helpmarq.com/r/keymap-ascii-heat.json
Source
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?: string;
  label?: string;
  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-muted focus-visible:ring-2 focus-visible:ring-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-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>
  );
}
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.

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, --accent only on the input's focus ring. No canvas, zero dependencies.

Tags
keyboardheatmapasciimonotypingdata-visualizationlive