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.
npx shadcn add https://design.helpmarq.com /r/keymap-ascii-heat.jsonregistry/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>
);
}
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 <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.