Text input whose focus ring is a living membrane: a noise-displaced canvas loop that breathes on idle, dilates on focus, sends a peristaltic pulse from the caret on each keystroke, constricts and quivers on error, and exhales once on valid submit.
npx shadcn add https://design.helpmarq.com /r/input-focus-membrane.jsonregistry/core/input-focus-membrane/component.tsx"use client";
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
// ---------------------------------------------------------------------------
// deterministic 2-octave value noise — no deps, stable across frames
// ---------------------------------------------------------------------------
function hash2(x: number, y: number) {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
return n - Math.floor(n);
}
function vnoise(x: number, y: number) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const xf = x - xi;
const yf = y - yi;
const u = xf * xf * (3 - 2 * xf);
const v = yf * yf * (3 - 2 * yf);
const a = hash2(xi, yi);
const b = hash2(xi + 1, yi);
const c = hash2(xi, yi + 1);
const d = hash2(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function noise2(x: number, y: number) {
return 0.65 * vnoise(x, y) + 0.35 * vnoise(x * 2.1 + 19.7, y * 2.1 + 7.3);
}
// cubic-bezier(0.22, 1, 0.36, 1) solved via Newton–Raphson
function makeBezier(p1x: number, p1y: number, p2x: number, p2y: number) {
const cx = 3 * p1x;
const bx = 3 * (p2x - p1x) - cx;
const ax = 1 - cx - bx;
const cy = 3 * p1y;
const by = 3 * (p2y - p1y) - cy;
const ay = 1 - cy - by;
const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;
const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;
const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
return (x: number) => {
if (x <= 0) return 0;
if (x >= 1) return 1;
let t = x;
for (let i = 0; i < 6; i++) {
const s = slopeX(t);
if (Math.abs(s) < 1e-6) break;
t -= (sampleX(t) - x) / s;
}
return sampleY(Math.min(1, Math.max(0, t)));
};
}
const glideEase = makeBezier(0.22, 1, 0.36, 1);
const TAU = Math.PI * 2;
const PAD = 24; // canvas overhang — worst-case displacement (6+8+~3px) fits
const POINTS = 96;
// the brief's noise2(s*3, ·) frequency (3 noise units per lap) sampled on a
// closed ring so the membrane has no seam where s wraps: circumference
// 2πR = 3 → R = 3/2π
const NOISE_R = 3 / TAU;
const SIGMA = 0.06; // gaussian pulse width, normalized perimeter
const PULSE_SPEED = 1.2; // perimeters per second
const PULSE_TAU = 0.6; // seconds
const TWO_SIGMA2 = 2 * SIGMA * SIGMA;
// ---------------------------------------------------------------------------
// RespireField — a real, fully native <input> under a decoration-only canvas
// membrane: a 96-point noise-displaced loop that breathes on idle, dilates on
// focus, sends a peristaltic pulse around the loop from the caret on each
// keystroke, constricts and quivers on error, and exhales once on valid
// submit. Direct-DOM rAF is the sole writer; the loop sleeps when blurred and
// every transient has settled below 0.05px.
// ---------------------------------------------------------------------------
export function RespireField({
error = false,
exhaleKey = 0,
className = "",
...inputProps
}: {
/** constricts the membrane -3px, quivers once, blends stroke toward red */
error?: boolean;
/** bump after a valid submit to trigger the single 8px exhale */
exhaleKey?: number;
/** classes for the wrapper; input styling is internal */
className?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "className">) {
const wrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const wakeRef = useRef<(() => void) | null>(null);
const errorRef = useRef(false);
const quiverStartRef = useRef(-1);
const exhaleStartRef = useRef(-1);
const prevErrRef = useRef(false);
const prevExRef = useRef(exhaleKey);
const [reduced, setReduced] = useState(false);
useEffect(() => {
setReduced(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduced) return;
const wrap = wrapRef.current;
const input = inputRef.current;
const canvas = canvasRef.current;
if (!wrap || !input || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const measure = document.createElement("canvas").getContext("2d");
// geometry — rebuilt on resize, read every frame
let w = 0;
let h = 0;
let dpr = 1;
let radius = 6;
let font = "";
let padLeft = 14;
let L = 1; // perimeter length in px
const px = new Float32Array(POINTS);
const py = new Float32Array(POINTS);
const nx = new Float32Array(POINTS);
const ny = new Float32Array(POINTS);
const dx = new Float32Array(POINTS); // displaced ring, rebuilt every frame
const dy = new Float32Array(POINTS);
// stroke endpoints resolved from CSS tokens (muted → foreground, error),
// re-resolved when the theme class on <html> flips
let mutC: [number, number, number] = [143, 143, 143];
let fgC: [number, number, number] = [237, 237, 237];
let errC: [number, number, number] = [234, 0, 29];
const parseColor = (
v: string,
fb: [number, number, number]
): [number, number, number] => {
if (!measure || !v) return fb;
measure.fillStyle = "#000000";
measure.fillStyle = v.trim();
const m = /^#([0-9a-f]{6})$/.exec(measure.fillStyle);
if (!m || !m[1]) return fb;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const deriveColors = () => {
const root = getComputedStyle(document.documentElement);
mutC = parseColor(root.getPropertyValue("--muted"), [143, 143, 143]);
fgC = parseColor(root.getPropertyValue("--foreground"), [237, 237, 237]);
errC = parseColor(root.getPropertyValue("--error"), [234, 0, 29]);
};
deriveColors();
// s=0 at the left end of the top edge, clockwise: top → TR arc → right
// → BR arc → bottom → BL arc → left → TL arc. Positions + outward normals.
const rebuild = () => {
const r = Math.min(6, w / 2, h / 2);
radius = r;
const sw = w - 2 * r;
const sh = h - 2 * r;
const arc = (Math.PI * r) / 2;
L = Math.max(1, 2 * sw + 2 * sh + 4 * arc);
const seg = [sw, arc, sh, arc, sw, arc, sh, arc];
for (let i = 0; i < POINTS; i++) {
let d = (i / POINTS) * L;
let k = 0;
while (k < 7 && d > (seg[k] ?? 0)) {
d -= seg[k] ?? 0;
k++;
}
let x = 0;
let y = 0;
let ox = 0;
let oy = 0;
switch (k) {
case 0:
x = r + d;
ox = 0;
oy = -1;
break;
case 1: {
const a = -Math.PI / 2 + d / r;
x = w - r + r * Math.cos(a);
y = r + r * Math.sin(a);
ox = Math.cos(a);
oy = Math.sin(a);
break;
}
case 2:
x = w;
y = r + d;
ox = 1;
break;
case 3: {
const a = d / r;
x = w - r + r * Math.cos(a);
y = h - r + r * Math.sin(a);
ox = Math.cos(a);
oy = Math.sin(a);
break;
}
case 4:
x = w - r - d;
y = h;
oy = 1;
break;
case 5: {
const a = Math.PI / 2 + d / r;
x = r + r * Math.cos(a);
y = h - r + r * Math.sin(a);
ox = Math.cos(a);
oy = Math.sin(a);
break;
}
case 6:
x = 0;
y = h - r - d;
ox = -1;
break;
default: {
const a = Math.PI + d / r;
x = r + r * Math.cos(a);
y = r + r * Math.sin(a);
ox = Math.cos(a);
oy = Math.sin(a);
break;
}
}
px[i] = x + PAD;
py[i] = y + PAD;
nx[i] = ox;
ny[i] = oy;
}
};
const resize = () => {
const rect = wrap.getBoundingClientRect();
w = rect.width;
h = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round((w + PAD * 2) * dpr));
canvas.height = Math.max(1, Math.round((h + PAD * 2) * dpr));
// a canvas is a replaced element: absolute + inset does NOT stretch it,
// it lays out at its intrinsic (buffer) size — dpr× too big. Pin the
// CSS size explicitly so buffer*1/dpr === CSS px and the membrane hugs
// the field instead of rendering as a giant dpr-scaled polygon.
canvas.style.width = `${w + PAD * 2}px`;
canvas.style.height = `${h + PAD * 2}px`;
const cs = getComputedStyle(input);
font = cs.font || `${cs.fontSize} ${cs.fontFamily}`;
padLeft = parseFloat(cs.paddingLeft) || 0;
rebuild();
};
resize();
// hot-path state — locals only, never React state
let raf = 0;
let last = 0;
let focused = document.activeElement === input;
let fp = focused ? 1 : 0; // focus progress 0..1 (offset, breath, stroke)
let focusFrom = fp;
let focusTo = fp;
let focusStart = -1;
let errMix = 0;
const pulses: { s: number; t: number }[] = [];
// caret x → normalized position on the top-edge run of the perimeter
const caretS = () => {
if (!measure) return 0.05;
let caret = input.value.length;
try {
const sel = input.selectionStart;
if (sel != null) caret = sel;
} catch {
// email/number inputs refuse the selection API — caret ≈ end of value
}
measure.font = font;
const tx =
padLeft +
measure.measureText(input.value.slice(0, caret)).width -
input.scrollLeft;
const d = Math.min(
Math.max(tx - radius, 0),
Math.max(1, w - 2 * radius)
);
return d / L;
};
const loop = (now: number) => {
const t = now / 1000;
const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
last = now;
// focus dilation: 0 → +6px over 350ms on the glide bezier, reversible
if (focusStart >= 0) {
const q = (now - focusStart) / 350;
if (q >= 1) {
fp = focusTo;
focusStart = -1;
} else {
fp = focusFrom + (focusTo - focusFrom) * glideEase(q);
}
}
// error blend eases toward its target, then snaps so sleep can trigger
const errTarget = errorRef.current ? 1 : 0;
errMix += (errTarget - errMix) * (1 - Math.exp(-dt * 12));
if (Math.abs(errMix - errTarget) < 0.01) errMix = errTarget;
// quiver: 14Hz, 1.2px, fading out over its 500ms window
let quiver = 0;
const qs = quiverStartRef.current;
if (qs >= 0) {
const age = now - qs;
if (age >= 500) quiverStartRef.current = -1;
else quiver = Math.sin(TAU * 14 * (age / 1000)) * 1.2 * (1 - age / 500);
}
// exhale: amplitude spikes +8px, decays over 700ms ease-out (cubic)
let exhale = 0;
const es = exhaleStartRef.current;
if (es >= 0) {
const p = (now - es) / 700;
if (p >= 1) exhaleStartRef.current = -1;
else {
const e = 1 - p;
exhale = 8 * e * e * e;
}
}
// prune dead pulses (5px * e^-2.6/0.6 ≈ 0.06px — under threshold)
for (let j = pulses.length - 1; j >= 0; j--) {
const pu = pulses[j];
if (!pu || now - pu.t > 2600) pulses.splice(j, 1);
}
const base = 6 * fp - 3 * errMix + quiver;
// idle breath 1.5px → 2.5px focused, on a 4.5s sine period; the sine
// never fully deflates the noise so the membrane always reads alive
const breathAmp =
(1.5 + 1.0 * fp) * (0.6 + 0.4 * Math.sin((TAU * t) / 4.5));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w + PAD * 2, h + PAD * 2);
for (let i = 0; i < POINTS; i++) {
const s = i / POINTS;
const n = noise2(
NOISE_R * Math.cos(s * TAU) + 7.3 + t * 0.15,
NOISE_R * Math.sin(s * TAU) + 3.1 + t * 0.12
);
let disp = base + n * (breathAmp + exhale);
// peristaltic pulses: two gaussian wavefronts leave the caret and
// travel opposite ways around the loop at 1.2 perimeters/s
for (const pu of pulses) {
const age = (now - pu.t) / 1000;
const amp = 5 * Math.exp(-age / PULSE_TAU);
const travel = PULSE_SPEED * age;
let d1 = Math.abs(s - ((pu.s + travel) % 1));
d1 = Math.min(d1, 1 - d1);
let d2 = Math.abs(s - ((((pu.s - travel) % 1) + 1) % 1));
d2 = Math.min(d2, 1 - d2);
disp +=
amp *
(Math.exp(-(d1 * d1) / TWO_SIGMA2) +
Math.exp(-(d2 * d2) / TWO_SIGMA2));
}
// hard ceiling: transients can momentarily stack — never leave the
// canvas overhang, so the ring always hugs the field
if (disp > PAD - 1) disp = PAD - 1;
dx[i] = (px[i] ?? 0) + (nx[i] ?? 0) * disp;
dy[i] = (py[i] ?? 0) + (ny[i] ?? 0) * disp;
}
// closed Catmull-Rom spline through the displaced ring (bezier form,
// tangents (p2-p0)/6) — 96 linear segments leave each 6px corner arc
// with ~1 sample and read as sharp angles; the spline keeps it round
ctx.beginPath();
ctx.moveTo(dx[0] ?? 0, dy[0] ?? 0);
for (let i = 0; i < POINTS; i++) {
const p0 = (i + POINTS - 1) % POINTS;
const p2 = (i + 1) % POINTS;
const p3 = (i + 2) % POINTS;
ctx.bezierCurveTo(
(dx[i] ?? 0) + ((dx[p2] ?? 0) - (dx[p0] ?? 0)) / 6,
(dy[i] ?? 0) + ((dy[p2] ?? 0) - (dy[p0] ?? 0)) / 6,
(dx[p2] ?? 0) - ((dx[p3] ?? 0) - (dx[i] ?? 0)) / 6,
(dy[p2] ?? 0) - ((dy[p3] ?? 0) - (dy[i] ?? 0)) / 6,
dx[p2] ?? 0,
dy[p2] ?? 0
);
}
ctx.closePath();
// stroke: muted token blurred → foreground token focused, blended
// toward the error token at 0.6 alpha while error holds (status only)
const cr0 = mutC[0] + (fgC[0] - mutC[0]) * fp;
const cg0 = mutC[1] + (fgC[1] - mutC[1]) * fp;
const cb0 = mutC[2] + (fgC[2] - mutC[2]) * fp;
const cr = Math.round(cr0 + (errC[0] - cr0) * errMix);
const cg = Math.round(cg0 + (errC[1] - cg0) * errMix);
const cb = Math.round(cb0 + (errC[2] - cb0) * errMix);
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${1 - 0.4 * errMix})`;
ctx.lineWidth = 1;
ctx.lineJoin = "round";
ctx.stroke();
// sleep: blurred and every transient fully settled (< 0.05px) — the
// idle breath freezes at its current phase until the next wake
const settled =
!focused &&
focusStart < 0 &&
fp === 0 &&
pulses.length === 0 &&
errMix === 0 &&
!errorRef.current &&
quiverStartRef.current < 0 &&
exhaleStartRef.current < 0;
if (settled) {
raf = 0;
last = 0;
return;
}
raf = requestAnimationFrame(loop);
};
let onscreen = true;
const wake = () => {
if (onscreen && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
wakeRef.current = wake;
wake(); // first paint; settles immediately if nothing is alive
const onFocus = () => {
focused = true;
focusFrom = fp;
focusTo = 1;
focusStart = performance.now();
wake();
};
const onBlur = () => {
focused = false;
focusFrom = fp;
focusTo = 0;
focusStart = performance.now();
wake();
};
const onInput = () => {
pulses.push({ s: caretS(), t: performance.now() });
if (pulses.length > 24) pulses.shift();
wake();
};
input.addEventListener("focus", onFocus);
input.addEventListener("blur", onBlur);
input.addEventListener("input", onInput);
const ro = new ResizeObserver(() => {
resize();
wake();
});
ro.observe(wrap);
// theme flip (class change on <html>) → re-derive stroke tokens + repaint
const mo = new MutationObserver(() => {
deriveColors();
wake();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
// offscreen → park the rAF loop; back onscreen → repaint and resume
const io = new IntersectionObserver((entries) => {
onscreen = entries[entries.length - 1]?.isIntersecting ?? true;
if (!onscreen && raf) {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
} else if (onscreen) {
wake();
}
});
io.observe(wrap);
return () => {
cancelAnimationFrame(raf);
wakeRef.current = null;
ro.disconnect();
mo.disconnect();
io.disconnect();
input.removeEventListener("focus", onFocus);
input.removeEventListener("blur", onBlur);
input.removeEventListener("input", onInput);
};
}, [reduced]);
// error is a prop, not hot-path state: refs carry it into the loop
useEffect(() => {
errorRef.current = error;
if (error && !prevErrRef.current) {
quiverStartRef.current = performance.now();
}
prevErrRef.current = error;
wakeRef.current?.();
}, [error]);
useEffect(() => {
if (exhaleKey === prevExRef.current) return;
prevExRef.current = exhaleKey;
exhaleStartRef.current = performance.now();
wakeRef.current?.();
}, [exhaleKey]);
return (
<div ref={wrapRef} className={`relative ${className}`}>
<input
ref={inputRef}
{...inputProps}
aria-invalid={error}
className={
reduced
? // reduced motion: canvas hidden, standard border + default ring;
// error still visible via the border (canvas quiver is gone)
`w-full rounded-sm border bg-surface px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted/60 ${
error ? "border-[#ea001d]" : "border-border"
}`
: "w-full rounded-sm border border-transparent bg-surface px-3.5 py-2.5 text-sm text-foreground outline-none placeholder:text-muted/60"
}
/>
{!reduced && (
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute"
style={{ inset: -PAD }}
/>
)}
</div>
);
}
a general-purpose text input whose focus ring is a breathing membrane that pulses per keystroke and constricts on error; use for any field needing organic focus/validation feedback, not specifically passwords.
Build a text input whose focus ring is a living membrane. Keep a real, fully native <input> (focus, selection, autofill intact, aria-invalid mirroring the error prop) and overlay a decoration-only aria-hidden <canvas> (position absolute, inset -24px, pointer-events none). Critical sizing: a canvas is a replaced element, so absolute + inset does NOT stretch it — on every resize set both the backing store (css size * devicePixelRatio, dpr capped at 2) AND style.width/height explicitly, or the membrane renders dpr-times too large as a giant polygon. Compute the membrane as a closed loop of 96 points parameterized around the input's rounded-rect perimeter (6px corner radius, positions plus outward normals precomputed on resize), each point displaced along its outward normal by 2-octave value noise sampled on a closed ring (the noise2(s*3, t*0.15) frequency mapped onto a circle of radius 3/2pi so the loop has no seam), displacement clamped to the 24px overhang so the ring always hugs the field. Draw the displaced ring as a closed Catmull-Rom spline (bezier segments, tangents (p2-p0)/6) — 96 linear segments leave each corner arc with ~1 sample and read as sharp angles. Stroke colors come from CSS tokens resolved via getComputedStyle on documentElement at mount (--muted, --foreground, --error with #ea001d fallback, normalized through a scratch canvas fillStyle) and re-resolved by a MutationObserver watching the documentElement class, so theme flips restyle the membrane live. Idle breath: 1.5px amplitude on a 4.5s sine period. Focus: base offset glides 0 to +6px over 350ms on cubic-bezier(0.22, 1, 0.36, 1) via a Newton-Raphson bezier solver, breath amplitude rises to 2.5px, stroke lerps the muted token toward the foreground token. Keystroke: on each input event, map the caret x (measureText with the input's computed font, selectionStart in try/catch with end-of-value fallback for email inputs) to the top-edge perimeter position and launch a Gaussian pulse (sigma 0.06 normalized perimeter, 5px amplitude) as two wavefronts traveling opposite directions at 1.2 perimeters/s, decaying with tau 0.6s. Error prop: constrict -3px, quiver at 14Hz/1.2px for 500ms on the rising edge, stroke blends toward the error token at 0.6 alpha (status use only). Valid submit (bumped exhaleKey prop): breath amplitude spikes +8px and decays over 700ms cubic ease-out. Stroke stays 1px. All animation runs on a direct-DOM rAF loop with locals/refs only, no React state on the hot path; the loop sleeps when the input is blurred and every transient has settled below 0.05px, waking on focus, input, error, exhale, or theme changes, and an IntersectionObserver parks it entirely while the field is offscreen. All listeners and the Resize/Mutation/Intersection observers are torn down on unmount. prefers-reduced-motion: no canvas, standard 1px border-border (error state switches the border to the error red) with the default focus ring. Zero dependencies; Geist dark tokens (bg-surface field, text-foreground, rounded-sm).