Polygraph
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldPolygraph(el) on the mounted element instead.
<div class="field-polygraph">
<div class="field-polygraph__chart" aria-hidden="true">
<span class="field-polygraph__label" style="top:8px">SKIN</span>
<span class="field-polygraph__label" style="top:50px">PULSE</span>
<canvas class="field-polygraph__trace"></canvas>
<span class="field-polygraph__arm"></span>
<span class="field-polygraph__arm"></span>
</div>
<p class="field-polygraph__read" aria-hidden="true">
<span>
<i class="field-polygraph__dot"></i>
Recording
</span>
<span>
Reading ·
<b>calm</b>
</span>
</p>
<span class="field-polygraph__field">
<textarea id="field-polygraph" class="field-polygraph__input" rows="3" aria-label="Your statement" placeholder="Where were you on Tuesday night?"></textarea>
</span>
</div>.field-polygraph { --paper: light-dark(#fbfcfd, #e5e6e9); --grid: light-dark(#dbdde1, #7d8087); --ink: light-dark(#373a40, #121417); display: grid; gap: 8px; width: 262px; }
.field-polygraph__chart { position: relative; height: 92px; 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 0.800s 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: 212px; top: 0; width: 58px; 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; }
}function initFieldPolygraph(root) {
var W = 262;
var H = 92;
var PEN = 212; // where the pens write; the paper runs left from here
var SPEED = 30; // px per second
var LEVELS = ['calm', 'elevated', 'off the chart'];
var c = root.querySelector('.field-polygraph__trace');
var arms = root.querySelectorAll('.field-polygraph__arm');
var reading = root.querySelector('.field-polygraph__read b');
var input = root.querySelector('.field-polygraph__input');
var dpr = Math.min(window.devicePixelRatio || 1, 2);
c.width = W * dpr;
c.height = H * dpr;
var ctx = c.getContext('2d');
ctx.scale(dpr, dpr);
// One sample per pixel of paper for each pen; drawn as two polylines every frame.
var gsr = new Float32Array(PEN).fill(34);
var pulse = new Float32Array(PEN).fill(70);
var y = 34;
var v = 0;
var act = 0;
var phase = 0;
var carry = 0;
var kick = 0;
var last = performance.now();
var raf = 0;
var shown = -1;
function beat(p) {
if (p < 0.08) return Math.sin((p / 0.08) * Math.PI) * 0.18;
if (p < 0.2) return 0;
if (p < 0.24) return -((p - 0.2) / 0.04) * 0.25;
if (p < 0.29) return -0.25 + ((p - 0.24) / 0.05) * 1.25;
if (p < 0.34) return 1 - ((p - 0.29) / 0.05) * 1.35;
if (p < 0.38) return -0.35 + ((p - 0.34) / 0.04) * 0.35;
if (p < 0.6) return Math.sin(((p - 0.38) / 0.22) * Math.PI) * 0.22;
return 0;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// The ink follows the theme: the canvas reads its CSS color.
ctx.strokeStyle = getComputedStyle(c).color;
ctx.lineWidth = 1.3;
ctx.lineJoin = 'round';
[gsr, pulse].forEach(function (arr) {
ctx.beginPath();
for (var x = 0; x < PEN; x++) ctx[x ? 'lineTo' : 'moveTo'](x, arr[x]);
ctx.stroke();
});
arms.forEach(function (a, k) {
var tip = k ? pulse[PEN - 1] : gsr[PEN - 1];
var base = k ? 70 : 34;
a.style.transform = 'translateY(' + base + 'px) rotate(' + Math.atan2(base - tip, W - PEN + 8) * (180 / Math.PI) + 'deg)';
});
}
function tick(now) {
var 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) {
v -= kick * (110 + Math.random() * 90);
act = Math.min(act + kick * 0.9, 6);
kick = 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;
var p = 70 - beat(phase) * (9 + act * 1.6);
carry += SPEED * dt;
var step = Math.floor(carry);
carry -= step;
for (var 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();
var lv = act < 0.6 ? 0 : act < 2.6 ? 1 : 2;
if (lv !== shown) reading.textContent = LEVELS[(shown = lv)];
raf = requestAnimationFrame(tick);
}
function onKey(e) {
if (e.key.length === 1 || e.key === 'Backspace' || e.key === 'Enter') kick += 1;
}
draw();
input.addEventListener('keydown', onKey);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
return function () {
input.removeEventListener('keydown', onKey);
};
}
// Only run while the chart is on screen.
var io = new IntersectionObserver(function (entries) {
cancelAnimationFrame(raf);
if (entries[0].isIntersecting) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
});
io.observe(c);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
input.removeEventListener('keydown', onKey);
};
}
document.querySelectorAll('.field-polygraph').forEach(initFieldPolygraph);Goal: Create a "Polygraph" input.
Visual behavior: 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.
Technique:
- A real textarea feeds its keystrokes to a requestAnimationFrame loop that draws two traces on a canvas. The loop only runs while the chart is on screen (IntersectionObserver).
- The skin trace is a damped spring that each keystroke kicks. An activity level decays over time and raises both the spring's rest point and the pulse rate, which sets the reading: calm, elevated or off the chart.
- The pulse is a drawn heartbeat shape sampled over time. Both traces scroll left one sample per pixel of paper, and the two pen arms turn to point at the newest sample.
- The canvas reads its ink from the CSS color property, so the traces follow the theme.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Polygraph" input.
Visual behavior: 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.
Technique:
- A real textarea feeds its keystrokes to a requestAnimationFrame loop that draws two traces on a canvas. The loop only runs while the chart is on screen (IntersectionObserver).
- The skin trace is a damped spring that each keystroke kicks. An activity level decays over time and raises both the spring's rest point and the pulse rate, which sets the reading: calm, elevated or off the chart.
- The pulse is a drawn heartbeat shape sampled over time. Both traces scroll left one sample per pixel of paper, and the two pen arms turn to point at the newest sample.
- The canvas reads its ink from the CSS color property, so the traces follow the theme.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.