{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"typing-seismograph","type":"registry:component","title":"Typing seismograph","description":"Records the rhythm of your typing on a scrolling seismograph.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/typing-seismograph.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst STRIP_H = 96;\n// The pen sits near the right edge so most of the strip is history.\nconst PEN_INSET = 52;\n// The stylus arm's pivot, near the right edge.\nconst PIVOT_INSET = 14;\n// Paper speed, px per second: a word's worth of keys spans a thumb's width.\nconst PAPER_SPEED = 64;\n// Grid pitch on the paper, px.\nconst GRID = 16;\n// Needle spring: stiff and underdamped so each kick rings for a few cycles,\n// the way a real stylus chatters (about 5Hz, gone in under a second).\nconst NEEDLE_K = 900;\nconst NEEDLE_C = 11;\n// Base kick velocity in px/s; scaled by typing tempo below.\nconst KICK = 420;\n// Paper keeps rolling this long after the last key, then stops.\nconst IDLE_MS = 1400;\n// WPM is keystrokes over the last few seconds, 5 characters per word.\nconst WPM_WINDOW = 5000;\n\ntype Colors = {\n  ink: string;\n  grid: string;\n  axis: string;\n  arm: string;\n  paper: string;\n};\n\n// Soft clip so a hard kick flattens against the edge instead of leaving the\n// paper. Applied only when drawing, so the physics stays linear.\nfunction clip(y: number) {\n  const limit = STRIP_H / 2 - 6;\n  return limit * Math.tanh(y / limit);\n}\n\nfunction tempoFor(gap: number) {\n  // Fast typing hits harder; a key after a long pause is a light tap.\n  return Math.min(1.6, Math.max(0.45, 180 / gap));\n}\n\n// A short message someone typed a few seconds ago, already on the paper when\n// the strip mounts, so the idea reads before the first key. Run through the\n// same needle physics as live typing; seconds between keys, and a negative\n// entry is a backspace.\nconst EARLIER = [\n  0.12, 0.1, 0.14, 0.26, 0.11, 0.09, 0.13, 0.1, 0.12, 0.3, 0.1, 0.12, 0.28,\n  0.11, 0.1, 0.13, 0.09, 0.27, 0.12, 0.1, -0.34, -0.12, 0.3, 0.11,\n];\n// How long before the pen the recording ends, in seconds.\nconst EARLIER_ENDS = 1.6;\n\nfunction earlierTrace(width: number) {\n  const trace = new Float32Array(width);\n  const dt = 1 / PAPER_SPEED;\n  const total = width * dt;\n  const length = EARLIER.reduce((t, g) => t + Math.abs(g), 0);\n  const kicks: { t: number; v: number }[] = [];\n  let t = total - EARLIER_ENDS - length;\n  for (const g of EARLIER) {\n    t += Math.abs(g);\n    // Fixed jitter so the trace is the same on every visit.\n    const jitter = 0.9 + 0.2 * Math.abs(Math.sin(t * 13.7));\n    kicks.push({ t, v: Math.sign(g) * KICK * tempoFor(Math.abs(g) * 1000) * jitter });\n  }\n  let y = 0;\n  let v = 0;\n  let k = 0;\n  for (let i = 0; i < width; i++) {\n    const now = i * dt;\n    while (k < kicks.length && kicks[k].t <= now) v += kicks[k++].v;\n    for (let j = 0; j < 8; j++) {\n      v += (-NEEDLE_K * y - NEEDLE_C * v) * (dt / 8);\n      y += v * (dt / 8);\n    }\n    trace[i] = clip(y);\n  }\n  return trace;\n}\n\nexport function TypingSeismograph({\n  value,\n  onChange,\n  label,\n  placeholder,\n  className,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  label: string;\n  placeholder?: string;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const wpmRef = useRef<HTMLSpanElement>(null);\n  const [active, setActive] = useState(false);\n\n  // Everything the loop touches lives here, so typing never re-renders\n  // more than the textarea itself.\n  const sim = useRef({\n    y: 0,\n    v: 0,\n    samples: new Float32Array(0),\n    head: 0,\n    scroll: 0,\n    consumed: 0,\n    width: 0,\n    lastKey: 0,\n    keys: [] as number[],\n    burstStart: 0,\n    frame: 0,\n    last: 0,\n    colors: null as Colors | null,\n  });\n\n  const readColors = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const probe = (cls: string) => {\n      const el = wrap.querySelector<HTMLElement>(`[data-probe=\"${cls}\"]`);\n      return el ? getComputedStyle(el).color : \"#888\";\n    };\n    sim.current.colors = {\n      ink: probe(\"ink\"),\n      grid: probe(\"grid\"),\n      axis: probe(\"axis\"),\n      arm: probe(\"arm\"),\n      paper: probe(\"paper\"),\n    };\n  };\n\n  const draw = () => {\n    const canvas = canvasRef.current;\n    const s = sim.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx || !s.colors || !s.width) return;\n    const dpr = window.devicePixelRatio || 1;\n    const w = s.width;\n    const mid = STRIP_H / 2;\n    const pen = w - PEN_INSET;\n    const tip = clip(s.y);\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    ctx.clearRect(0, 0, w, STRIP_H);\n\n    // Grid scrolls with the paper: offset by how far it has travelled.\n    ctx.lineWidth = 1;\n    ctx.strokeStyle = s.colors.grid;\n    ctx.beginPath();\n    const shift = s.scroll % GRID;\n    for (let x = pen - shift; x > 0; x -= GRID) {\n      ctx.moveTo(Math.round(x) + 0.5, 0);\n      ctx.lineTo(Math.round(x) + 0.5, STRIP_H);\n    }\n    for (let y = mid - GRID * 2; y <= mid + GRID * 2; y += GRID) {\n      if (y === mid) continue;\n      ctx.moveTo(0, y + 0.5);\n      ctx.lineTo(w, y + 0.5);\n    }\n    ctx.stroke();\n    ctx.strokeStyle = s.colors.axis;\n    ctx.beginPath();\n    ctx.moveTo(0, mid + 0.5);\n    ctx.lineTo(w, mid + 0.5);\n    ctx.stroke();\n\n    // Trace: newest sample at the pen, older ones to the left. The\n    // sub-pixel part of the scroll keeps motion smooth between samples.\n    const n = s.samples.length;\n    const frac = s.scroll - s.consumed;\n    ctx.strokeStyle = s.colors.ink;\n    ctx.lineWidth = 1.5;\n    ctx.lineJoin = \"round\";\n    ctx.lineCap = \"round\";\n    ctx.beginPath();\n    ctx.moveTo(pen, mid - tip);\n    for (let i = 0; i < n; i++) {\n      const x = pen - frac - i;\n      if (x < -2) break;\n      const sample = s.samples[(s.head - 1 - i + n * 2) % n];\n      ctx.lineTo(x, mid - sample);\n    }\n    ctx.stroke();\n\n    // The stylus: an arm swinging on a pivot at the right edge, tapering\n    // to a pen that rests on the paper.\n    const px = w - PIVOT_INSET;\n    const tx = pen;\n    const ty = mid - tip;\n    const len = Math.hypot(tx - px, ty - mid) || 1;\n    const nx = -(ty - mid) / len;\n    const ny = (tx - px) / len;\n    ctx.fillStyle = s.colors.arm;\n    ctx.beginPath();\n    ctx.moveTo(px + nx * 3.5, mid + ny * 3.5);\n    ctx.lineTo(tx + nx * 1.2, ty + ny * 1.2);\n    ctx.lineTo(tx - nx * 1.2, ty - ny * 1.2);\n    ctx.lineTo(px - nx * 3.5, mid - ny * 3.5);\n    ctx.closePath();\n    ctx.fill();\n    ctx.beginPath();\n    ctx.arc(px, mid, 7, 0, Math.PI * 2);\n    ctx.fill();\n    ctx.fillStyle = s.colors.paper;\n    ctx.beginPath();\n    ctx.arc(px, mid, 2.5, 0, Math.PI * 2);\n    ctx.fill();\n    ctx.fillStyle = s.colors.ink;\n    ctx.beginPath();\n    ctx.arc(tx, ty, 3, 0, Math.PI * 2);\n    ctx.fill();\n  };\n\n  const updateWpm = (now: number) => {\n    const s = sim.current;\n    while (s.keys.length && now - s.keys[0] > WPM_WINDOW) s.keys.shift();\n    // Over a short burst, divide by the burst's real length (at least 2s,\n    // so three quick keys don't read as 200 WPM).\n    const span = Math.min(WPM_WINDOW, Math.max(2000, now - s.burstStart));\n    const wpm = Math.round(s.keys.length / 5 / (span / 60000));\n    if (wpmRef.current) wpmRef.current.textContent = String(wpm);\n  };\n\n  const push = (y: number) => {\n    const s = sim.current;\n    if (!s.samples.length) return;\n    s.samples[s.head] = y;\n    s.head = (s.head + 1) % s.samples.length;\n  };\n\n  const run = () => {\n    const s = sim.current;\n    if (s.frame) return;\n    s.last = performance.now();\n    setActive(true);\n    const tick = (now: number) => {\n      const dt = Math.min((now - s.last) / 1000, 1 / 30);\n      s.last = now;\n      // Substeps keep the stiff spring stable on slow frames.\n      const steps = 4;\n      const h = dt / steps;\n      const prevY = s.y;\n      for (let i = 0; i < steps; i++) {\n        s.v += (-NEEDLE_K * s.y - NEEDLE_C * s.v) * h;\n        s.y += s.v * h;\n      }\n      s.scroll += PAPER_SPEED * dt;\n      const whole = Math.floor(s.scroll) - s.consumed;\n      for (let i = 1; i <= whole; i++) {\n        push(clip(prevY + ((s.y - prevY) * i) / whole));\n      }\n      s.consumed += whole;\n      draw();\n      updateWpm(now);\n      const resting =\n        now - s.lastKey > IDLE_MS && Math.abs(s.y) < 0.1 && Math.abs(s.v) < 1;\n      if (resting) {\n        s.frame = 0;\n        s.y = 0;\n        s.v = 0;\n        draw();\n        setActive(false);\n        return;\n      }\n      s.frame = requestAnimationFrame(tick);\n    };\n    s.frame = requestAnimationFrame(tick);\n  };\n\n  const kick = (direction: 1 | -1, size = 1) => {\n    const s = sim.current;\n    const now = performance.now();\n    const gap = s.lastKey ? now - s.lastKey : Infinity;\n    if (gap > IDLE_MS) s.burstStart = now;\n    s.lastKey = now;\n    s.keys.push(now);\n    const tempo = tempoFor(gap);\n    const jitter = 0.85 + Math.random() * 0.3;\n    if (reduceMotion) {\n      // No rolling paper: each key steps the paper and leaves one tick.\n      const amp = clip(direction * tempo * size * 18);\n      for (let i = 0; i < 3; i++) push(amp * (1 - i / 3));\n      for (let i = 0; i < 5; i++) push(0);\n      s.scroll += 8;\n      s.consumed += 8;\n      updateWpm(now);\n      draw();\n      return;\n    }\n    s.v += direction * KICK * tempo * size * jitter;\n    run();\n  };\n\n  // Size the canvas to its box at device resolution, keeping the most\n  // recent history when the width changes.\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas) return;\n    const s = sim.current;\n    readColors();\n    const resize = () => {\n      const w = Math.round(canvas.clientWidth);\n      if (!w || w === s.width) return;\n      const dpr = window.devicePixelRatio || 1;\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(STRIP_H * dpr);\n      const old = s.samples;\n      // First size: start with the earlier message on the paper.\n      const next = old.length ? new Float32Array(w) : earlierTrace(w);\n      for (let i = 0; i < Math.min(old.length, w); i++) {\n        next[w - 1 - i] = old[(s.head - 1 - i + old.length * 2) % old.length];\n      }\n      s.samples = next;\n      s.head = 0;\n      s.width = w;\n      draw();\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n\n    // Canvas pixels don't follow CSS, so repaint when the theme flips.\n    const repaint = () => {\n      readColors();\n      draw();\n    };\n    const mo = new MutationObserver(repaint);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"data-theme\", \"class\", \"style\"],\n    });\n    const media = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    media.addEventListener(\"change\", repaint);\n\n    return () => {\n      ro.disconnect();\n      mo.disconnect();\n      media.removeEventListener(\"change\", repaint);\n      cancelAnimationFrame(s.frame);\n      s.frame = 0;\n    };\n  }, []);\n\n  return (\n    <div ref={wrapRef} className={cn(\"w-[min(460px,100%)]\", className)}>\n      <label\n        htmlFor={`${id}-field`}\n        className=\"mb-2 block text-sm font-medium text-foreground\"\n      >\n        {label}\n      </label>\n      <div className=\"overflow-hidden rounded-xl bg-background shadow-raised transition-[box-shadow] duration-150 ease-out focus-within:shadow-[0_0_0_1.5px_var(--foreground)]\">\n        <textarea\n          id={`${id}-field`}\n          value={value}\n          placeholder={placeholder}\n          aria-describedby={`${id}-wpm`}\n          rows={4}\n          onChange={(e) => {\n            const type = (e.nativeEvent as InputEvent).inputType ?? \"\";\n            const delta = e.target.value.length - value.length;\n            onChange(e.target.value);\n            if (type.startsWith(\"delete\") || delta < 0) {\n              kick(-1);\n            } else {\n              // A paste is one big jolt, not a burst of keys.\n              kick(1, delta > 1 ? Math.min(2, 1 + delta / 40) : 1);\n            }\n          }}\n          className=\"block h-28 w-full resize-none bg-transparent px-3.5 py-3 text-[15px] leading-relaxed text-foreground outline-hidden placeholder:text-muted-foreground\"\n        />\n        <div className=\"relative border-t border-border bg-muted\">\n          <canvas\n            ref={canvasRef}\n            aria-hidden\n            className=\"block w-full\"\n            // The oldest trace fades out at the left, like paper leaving\n            // the lit window of the drum.\n            style={{\n              height: STRIP_H,\n              maskImage: \"linear-gradient(to right, transparent, black 56px)\",\n            }}\n          />\n          {/* Hidden swatches: canvas can't use tokens, so it reads their\n              resolved colors from these. */}\n          <span data-probe=\"ink\" className=\"hidden text-foreground\" />\n          <span data-probe=\"grid\" className=\"hidden text-foreground/[0.06]\" />\n          <span data-probe=\"axis\" className=\"hidden text-foreground/15\" />\n          <span data-probe=\"arm\" className=\"hidden text-muted-foreground\" />\n          <span data-probe=\"paper\" className=\"hidden text-muted\" />\n        </div>\n      </div>\n      <div className=\"mt-2.5 flex items-center justify-between text-sm\">\n        <span className=\"flex items-center gap-2 text-muted-foreground\">\n          <span\n            aria-hidden\n            className={cn(\n              \"size-1.5 rounded-full transition-[background-color] duration-200 ease-out\",\n              active ? \"bg-destructive\" : \"bg-foreground/20\",\n            )}\n          />\n          {active ? \"Recording\" : \"At rest\"}\n        </span>\n        <span id={`${id}-wpm`} className=\"text-muted-foreground\">\n          <span\n            ref={wpmRef}\n            className={cn(\n              \"font-semibold text-foreground tabular-nums transition-[opacity] duration-200 ease-out\",\n              !active && \"opacity-60\",\n            )}\n          >\n            0\n          </span>{\" \"}\n          words per minute\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport default function TypingSeismographDemo() {\n  const [text, setText] = useState(\"\");\n  return (\n    <TypingSeismograph\n      label=\"Message\"\n      placeholder=\"Type something and watch the needle\"\n      value={text}\n      onChange={setText}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/typing-seismograph"}