Polygraph

No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';

const W = 262;
const H = 92;
const PEN = 212; // where the pens write; the paper runs left from here
const SPEED = 30; // px per second
const LEVELS = ['calm', 'elevated', 'off the chart'];

const css = `
  .field-polygraph { --paper: light-dark(#fbfcfd, #e5e6e9); --grid: light-dark(#dbdde1, #7d8087); --ink: light-dark(#373a40, #121417); display: grid; gap: 8px; width: ${W}px; }
  .field-polygraph__chart { position: relative; height: ${H}px; overflow: hidden; border-radius: 6px; background: linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 24px 100%, linear-gradient(color-mix(in oklab, var(--grid) 70%, transparent) 1px, transparent 1px) 0 0 / 100% 12px, linear-gradient(90deg, color-mix(in oklab, var(--grid) 45%, transparent) 1px, transparent 1px) 0 0 / 6px 100%, var(--paper); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--grid) 80%, transparent), 0 8px 14px -8px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); animation: field-polygraph-feed ${(24 / SPEED).toFixed(3)}s linear infinite; }
  @keyframes field-polygraph-feed { to { background-position: -24px 0, 0 0, -24px 0, 0 0; } }
  .field-polygraph__trace { position: absolute; inset: 0; width: 100%; height: 100%; color: var(--ink); }
  .field-polygraph__label { position: absolute; left: 6px; color: color-mix(in oklab, var(--ink) 60%, var(--paper)); font: 700 6.5px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 0.1em; }
  /* The pen arms: hinged off the right edge, their tips ride the traces. */
  .field-polygraph__arm { position: absolute; left: ${PEN}px; top: 0; width: ${W - PEN + 8}px; height: 2px; margin-top: -1px; background: linear-gradient(90deg, var(--ink) 0 4px, light-dark(#6d7179, #7d8087) 4px); transform-origin: 100% 50%; border-radius: 1px; }
  .field-polygraph__field { position: relative; }
  .field-polygraph__input { display: block; box-sizing: border-box; width: 100%; height: 66px; margin: 0; padding: 6px 10px; resize: none; border: 0; border-radius: 6px; background: repeating-linear-gradient(transparent 0 19px, light-dark(#dbdde1, #2e3035) 19px 20px) 0 5px / 100% 100% no-repeat, light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font: 15px/20px 'American Typewriter', 'Courier New', Courier, monospace; outline: none; }
  .field-polygraph__input::placeholder { color: light-dark(#6d7179, #7d8087); }
  .field-polygraph__input:focus-visible { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), 0 0 0 2px light-dark(#5f626a, #a7abb2); }
  .field-polygraph__read { display: flex; justify-content: space-between; color: light-dark(#5f626a, #a7abb2); font: 500 11px system-ui, sans-serif; }
  .field-polygraph__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
  .field-polygraph__dot { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); animation: field-polygraph-rec 1.2s steps(1) infinite; }
  @keyframes field-polygraph-rec { 50% { opacity: 0.2; } }
  @media (prefers-reduced-motion: reduce) {
    .field-polygraph * { animation: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const canvas = useRef<HTMLCanvasElement>(null);
  const arms = useRef<(HTMLSpanElement | null)[]>([]);
  const kick = useRef(0);
  const [level, setLevel] = useState(0);

  useEffect(() => {
    const c = canvas.current!;
    const dpr = Math.min(devicePixelRatio || 1, 2);
    c.width = W * dpr;
    c.height = H * dpr;
    const ctx = c.getContext('2d')!;
    ctx.scale(dpr, dpr);
    // One sample per pixel of paper for each pen; drawn as two polylines every frame.
    const gsr = new Float32Array(PEN).fill(34);
    const pulse = new Float32Array(PEN).fill(70);
    let y = 34;
    let v = 0;
    let act = 0;
    let phase = 0;
    let carry = 0;
    let last = performance.now();
    let raf = 0;
    let shown = -1;
    const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const beat = (p: number) => (p < 0.08 ? Math.sin((p / 0.08) * Math.PI) * 0.18 : p < 0.2 ? 0 : p < 0.24 ? -((p - 0.2) / 0.04) * 0.25 : p < 0.29 ? -0.25 + ((p - 0.24) / 0.05) * 1.25 : p < 0.34 ? 1 - ((p - 0.29) / 0.05) * 1.35 : p < 0.38 ? -0.35 + ((p - 0.34) / 0.04) * 0.35 : p < 0.6 ? Math.sin(((p - 0.38) / 0.22) * Math.PI) * 0.22 : 0);
    const draw = () => {
      ctx.clearRect(0, 0, W, H);
      ctx.strokeStyle = getComputedStyle(c).color;
      ctx.lineWidth = 1.3;
      ctx.lineJoin = 'round';
      for (const arr of [gsr, pulse]) {
        ctx.beginPath();
        for (let x = 0; x < PEN; x++) ctx[x ? 'lineTo' : 'moveTo'](x, arr[x]);
        ctx.stroke();
      }
      arms.current.forEach((a, k) => {
        if (!a) return;
        const tip = k ? pulse[PEN - 1] : gsr[PEN - 1];
        const base = k ? 70 : 34;
        a.style.transform = `translateY(${base}px) rotate(${Math.atan2(base - tip, W - PEN + 8) * (180 / Math.PI)}deg)`;
      });
    };
    const tick = (now: number) => {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      // A keystroke kicks the skin pen up; the spring brings it back down.
      if (kick.current) {
        v -= kick.current * (110 + Math.random() * 90);
        act = Math.min(act + kick.current * 0.9, 6);
        kick.current = 0;
      }
      act *= Math.exp(-dt * 0.9);
      v += ((34 - act * 2.5 - y) * 170 - v * 11) * dt;
      y = Math.max(6, Math.min(56, y + v * dt));
      phase = (phase + dt * (1.1 + act * 0.35)) % 1;
      const p = 70 - beat(phase) * (9 + act * 1.6);
      carry += SPEED * dt;
      const step = Math.floor(carry);
      carry -= step;
      for (let s = 0; s < step; s++) {
        gsr.copyWithin(0, 1);
        pulse.copyWithin(0, 1);
        gsr[PEN - 1] = y + (Math.random() - 0.5) * 0.6;
        pulse[PEN - 1] = p;
      }
      draw();
      const lv = act < 0.6 ? 0 : act < 2.6 ? 1 : 2;
      if (lv !== shown) setLevel((shown = lv));
      raf = requestAnimationFrame(tick);
    };
    draw();
    if (still) return;
    // Only run while the chart is on screen.
    const io = new IntersectionObserver(([e]) => {
      cancelAnimationFrame(raf);
      if (e.isIntersecting) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    });
    io.observe(c);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <>
      <style href="kit-inputs-polygraph" precedence="kit">
        {css}
      </style>
      <div className="field-polygraph">
        <div className="field-polygraph__chart" aria-hidden="true">
          <span className="field-polygraph__label" style={{ top: 8 }}>
            SKIN
          </span>
          <span className="field-polygraph__label" style={{ top: 50 }}>
            PULSE
          </span>
          <canvas ref={canvas} className="field-polygraph__trace" />
          {[0, 1].map((k) => (
            <span key={k} ref={(el) => void (arms.current[k] = el)} className="field-polygraph__arm" />
          ))}
        </div>
        <p className="field-polygraph__read" aria-hidden="true">
          <span>
            <i className="field-polygraph__dot" />
            Recording
          </span>
          <span>
            Reading · <b>{LEVELS[level]}</b>
          </span>
        </p>
        <span className="field-polygraph__field">
          <textarea
            id={id}
            className="field-polygraph__input"
            rows={3}
            aria-label="Your statement"
            placeholder="Where were you on Tuesday night?"
            onKeyDown={(e) => {
              if (e.key.length === 1 || e.key === 'Backspace' || e.key === 'Enter') kick.current += 1;
            }}
          />
        </span>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-polygraph
Source on GitHub

About this component

A statement field wired to a chart recorder: every keystroke kicks the skin pen, the pulse quickens with your typing, and the reading calms down when you stop.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, state change, focus
Techniques
raf, spring, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native textarea named "Your statement" with a visible placeholder; focus outlines it. The chart and the reading are aria-hidden: they decorate the field, the text is the input. Under prefers-reduced-motion the chart stays still.

More inputs

See all
Punch Card
Sand
Shredder
Sieve