Headline rendered as running-stitch embroidery: the cursor is a seam ripper that picks a letter's stitches loose into a gravity-swayed dangling thread, lingering unravels it into a static pile, and a click (or the Re-sew button) sews it back in.
npx shadcn add https://design.helpmarq.com /r/text-stitch-unpick.jsonregistry/loud/text-stitch-unpick/component.tsx"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
// ---------------------------------------------------------------------------
// StitchPick — a headline rendered as running-stitch embroidery that a
// passing cursor (a seam ripper) picks loose, letter by letter. Each glyph is
// two offset, dashed, fill:none SVG <text> strokes (thread texture, subtle
// two-tone shimmer via a second stroke color at low opacity) laid out on a
// monospace grid so per-letter hit zones are trivial index math, no DOM
// measurement of proportional glyph widths.
//
// Per-letter state machine, entirely local to that letter:
// stitched -> pointer enters its zone -> picked (thread detaches, dangles
// on a 4-point verlet chain: gravity + a little sway, driven
// by a shared rAF loop, direct SVG attribute writes on a path
// ref — no React state on the physics hot path).
// picked -> pointer leaves before the dwell threshold -> stitched again
// (retracts: the dangling thread fades, the dash redraws).
// -> dwell threshold elapses while still hovered -> unraveled
// (thread fully detaches into a static loose pile at the
// glyph's baseline; physics stops, the pile just sits there).
// unraveled -> click that letter, or the always-present "Re-sew" button
// (which resews every unraveled letter at once) -> stitched,
// dash redraws with a short per-letter delayed stagger so it
// reads as sewn back in order rather than snapping at once.
//
// A single onPointerMove/onPointerLeave/onClick on the SVG root does the hit
// testing (glyph index = floor(localX / advance)) rather than per-glyph DOM
// listeners. Reduced motion skips the whole state machine: hover just
// brightens that letter's thread stroke opacity, instantly, no dangling,
// no pile.
// ---------------------------------------------------------------------------
type LetterState = "stitched" | "picked" | "unraveled";
export interface StitchPickProps {
text?: string;
/** Glyph font size in px. Default 56. */
size?: number;
/** Hover dwell before a picked letter fully unravels into a pile, ms. Default 650. */
dwellMs?: number;
className?: string;
}
const POINTS = 4;
const GRAVITY = 0.055;
const DAMPING = 0.96;
const SEGMENT = 4.2;
const CONSTRAINT_ITERS = 3;
interface ThreadSim {
x: number[];
y: number[];
px: number[];
py: number[];
anchorX: number;
anchorY: number;
seed: number;
}
function makeSim(anchorX: number, anchorY: number, seed: number): ThreadSim {
const x = new Array(POINTS).fill(anchorX);
const y = new Array(POINTS).fill(anchorY);
return { x, y, px: [...x], py: [...y], anchorX, anchorY, seed };
}
function stepSim(sim: ThreadSim, t: number) {
sim.x[0] = sim.anchorX;
sim.y[0] = sim.anchorY;
sim.px[0] = sim.anchorX;
sim.py[0] = sim.anchorY;
for (let i = 1; i < POINTS; i++) {
const vx = (sim.x[i] - sim.px[i]) * DAMPING;
const vy = (sim.y[i] - sim.py[i]) * DAMPING;
const sway = Math.sin(t * 0.004 + sim.seed + i) * 0.06;
sim.px[i] = sim.x[i];
sim.py[i] = sim.y[i];
sim.x[i] += vx + sway;
sim.y[i] += vy + GRAVITY;
}
for (let iter = 0; iter < CONSTRAINT_ITERS; iter++) {
for (let i = 1; i < POINTS; i++) {
const ax = sim.x[i - 1];
const ay = sim.y[i - 1];
const dx = sim.x[i] - ax;
const dy = sim.y[i] - ay;
const dist = Math.hypot(dx, dy) || 0.0001;
const diff = (dist - SEGMENT) / dist;
if (i === 1) {
sim.x[i] -= dx * diff;
sim.y[i] -= dy * diff;
} else {
sim.x[i] -= dx * diff * 0.5;
sim.y[i] -= dy * diff * 0.5;
sim.x[i - 1] += dx * diff * 0.5;
sim.y[i - 1] += dy * diff * 0.5;
}
}
}
}
function pathFor(sim: ThreadSim): string {
let d = `M ${sim.x[0].toFixed(1)} ${sim.y[0].toFixed(1)}`;
for (let i = 1; i < POINTS; i++) d += ` L ${sim.x[i].toFixed(1)} ${sim.y[i].toFixed(1)}`;
return d;
}
export function StitchPick({ text = "UNRAVEL", size = 56, dwellMs = 650, className = "" }: StitchPickProps) {
const glyphs = useMemo(() => Array.from(text), [text]);
const svgRef = useRef<SVGSVGElement | null>(null);
const measureRef = useRef<SVGTextElement | null>(null);
const threadPathRefs = useRef<(SVGPathElement | null)[]>([]);
const reducedRef = useRef(false);
const [advance, setAdvance] = useState(size * 0.62);
const [states, setStates] = useState<LetterState[]>(() => glyphs.map(() => "stitched"));
const statesRef = useRef(states);
statesRef.current = states;
const [brightIndex, setBrightIndex] = useState(-1);
const hoveredRef = useRef(-1);
const dwellTimers = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const sims = useRef<Record<number, ThreadSim>>({});
// Indices currently animating, tracked independently of `states` — the rAF
// loop reads this ref directly instead of deriving liveness from
// statesRef, since a pointermove-triggered setState is not guaranteed to
// flush before the next animation frame runs.
const activeRef = useRef<Set<number>>(new Set());
const rafRef = useRef<number | undefined>(undefined);
const fontSize = size;
const height = fontSize * 1.7;
const baseline = fontSize * 1.05;
const width = Math.max(1, glyphs.length) * advance;
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedRef.current = mq.matches;
const onChange = () => {
reducedRef.current = mq.matches;
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const measure = useCallback(() => {
const el = measureRef.current;
if (!el) return;
const len = el.getComputedTextLength();
if (len > 0) setAdvance(len);
}, []);
useEffect(() => {
measure();
if (typeof document !== "undefined" && "fonts" in document) {
document.fonts.ready.then(measure).catch(() => {});
}
}, [measure, size]);
const runLoop = useCallback(() => {
const loop = (t: number) => {
for (const i of activeRef.current) {
const sim = sims.current[i];
if (!sim) continue;
stepSim(sim, t);
const el = threadPathRefs.current[i];
if (el) el.setAttribute("d", pathFor(sim));
}
if (activeRef.current.size > 0) {
rafRef.current = requestAnimationFrame(loop);
} else {
rafRef.current = undefined;
}
};
if (rafRef.current === undefined) {
rafRef.current = requestAnimationFrame(loop);
}
}, []);
useEffect(
() => () => {
if (rafRef.current !== undefined) cancelAnimationFrame(rafRef.current);
Object.values(dwellTimers.current).forEach((id) => clearTimeout(id));
},
[]
);
// Reset per-letter state when the headline text itself changes — states
// is a fixed-length array seeded once from the initial glyph count, so a
// later prop change would otherwise leave extra/changed glyphs stuck with
// no state entry (invisible, inert to hover).
const lastTextRef = useRef(text);
useEffect(() => {
if (lastTextRef.current === text) return;
lastTextRef.current = text;
Object.values(dwellTimers.current).forEach((id) => clearTimeout(id));
dwellTimers.current = {};
sims.current = {};
activeRef.current.clear();
hoveredRef.current = -1;
setStates(glyphs.map(() => "stitched"));
}, [text, glyphs]);
const setLetterState = useCallback((i: number, next: LetterState) => {
setStates((prev) => {
if (prev[i] === next) return prev;
const copy = prev.slice();
copy[i] = next;
return copy;
});
}, []);
const enterLetter = useCallback(
(i: number) => {
if (i < 0 || i >= glyphs.length || glyphs[i] === " ") return;
if (statesRef.current[i] !== "stitched") return;
const anchorX = i * advance + advance / 2;
const anchorY = baseline + 2;
sims.current[i] = makeSim(anchorX, anchorY, i * 1.37);
activeRef.current.add(i);
setLetterState(i, "picked");
runLoop();
clearTimeout(dwellTimers.current[i]);
dwellTimers.current[i] = setTimeout(() => {
if (statesRef.current[i] === "picked") {
activeRef.current.delete(i);
setLetterState(i, "unraveled");
}
}, dwellMs);
},
[advance, baseline, dwellMs, glyphs, runLoop, setLetterState]
);
const leaveLetter = useCallback(
(i: number) => {
if (i < 0 || i >= glyphs.length) return;
if (statesRef.current[i] === "picked") {
clearTimeout(dwellTimers.current[i]);
activeRef.current.delete(i);
setLetterState(i, "stitched");
}
},
[glyphs.length, setLetterState]
);
const indexAt = (clientX: number) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect || advance <= 0) return -1;
const idx = Math.floor((clientX - rect.left) / advance);
return Math.min(glyphs.length - 1, Math.max(0, idx));
};
const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {
const idx = indexAt(e.clientX);
if (reducedRef.current) {
if (idx !== brightIndex) setBrightIndex(idx);
return;
}
if (idx === hoveredRef.current) return;
leaveLetter(hoveredRef.current);
enterLetter(idx);
hoveredRef.current = idx;
};
const onPointerLeave = () => {
if (reducedRef.current) {
setBrightIndex(-1);
return;
}
leaveLetter(hoveredRef.current);
hoveredRef.current = -1;
};
const onClickLetter = (e: ReactMouseEvent<SVGSVGElement>) => {
const idx = indexAt(e.clientX);
if (idx >= 0 && statesRef.current[idx] === "unraveled") {
clearTimeout(dwellTimers.current[idx]);
setLetterState(idx, "stitched");
}
};
const resewAll = () => {
Object.values(dwellTimers.current).forEach((id) => clearTimeout(id));
dwellTimers.current = {};
setStates((prev) => prev.map(() => "stitched" as LetterState));
};
const anyUnraveled = states.some((s) => s === "unraveled");
return (
<div className={`inline-flex flex-col items-center gap-4 ${className}`}>
<style>{CSS}</style>
<svg
ref={svgRef}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="ns-stitch-svg select-none"
role="img"
aria-label={text}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onClick={onClickLetter}
>
<text
ref={measureRef}
x={-9999}
y={-9999}
fontFamily="var(--font-mono)"
fontSize={fontSize}
fontWeight={700}
aria-hidden="true"
>
{"M"}
</text>
{glyphs.map((ch, i) => {
const cx = i * advance;
const state = states[i];
if (ch === " ") return null;
const bright = i === brightIndex;
return (
<g key={i} data-letter-state={state}>
<g
className="ns-stitch-thread"
style={{
opacity: state === "stitched" ? 1 : 0,
transitionDelay: state === "stitched" ? `${i * 18}ms` : "0ms",
}}
>
<text
x={cx}
y={baseline}
fontFamily="var(--font-mono)"
fontSize={fontSize}
fontWeight={700}
fill="none"
stroke="var(--foreground)"
strokeOpacity={bright ? 1 : 0.85}
strokeWidth={1.6}
strokeDasharray="3 2.4"
strokeLinecap="round"
aria-hidden="true"
>
{ch}
</text>
<text
x={cx + 0.6}
y={baseline - 0.6}
fontFamily="var(--font-mono)"
fontSize={fontSize}
fontWeight={700}
fill="none"
stroke="var(--accent)"
strokeOpacity={bright ? 0.4 : 0.22}
strokeWidth={1}
strokeDasharray="3 2.4"
strokeLinecap="round"
aria-hidden="true"
>
{ch}
</text>
</g>
{state === "picked" && (
<path
ref={(el) => {
threadPathRefs.current[i] = el;
}}
d={`M ${cx + advance / 2} ${baseline + 2} L ${cx + advance / 2} ${baseline + 2}`}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.4}
strokeDasharray="2 2"
strokeLinecap="round"
aria-hidden="true"
/>
)}
{state === "unraveled" && (
<g className="ns-stitch-pile" aria-hidden="true">
<path
d={`M ${cx + advance * 0.16} ${baseline - 2}
q ${advance * 0.16} 12 ${advance * 0.34} 6
q ${advance * 0.22} -9 ${advance * 0.42} 3
q -${advance * 0.1} 9 ${advance * 0.1} 13
q ${advance * 0.14} -5 ${advance * 0.28} 0`}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.5}
strokeDasharray="2.2 2.2"
strokeLinecap="round"
/>
<path
d={`M ${cx + advance * 0.22} ${baseline + 1}
q ${advance * 0.12} 8 ${advance * 0.3} 4`}
fill="none"
stroke="var(--accent)"
strokeOpacity={0.3}
strokeWidth={1}
strokeDasharray="2 2"
strokeLinecap="round"
/>
</g>
)}
</g>
);
})}
</svg>
<button
type="button"
onClick={resewAll}
className="ns-stitch-resew rounded-[6px] border border-border px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-foreground hover:bg-border/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{anyUnraveled ? "Re-sew" : "Re-sew (nothing loose)"}
</button>
</div>
);
}
const CSS = `
.ns-stitch-thread{transition:opacity 200ms ease-out;}
.ns-stitch-thread text{transition:stroke-opacity 150ms ease-out;}
.ns-stitch-pile{animation:ns-stitch-settle 220ms cubic-bezier(0.16,1,0.3,1);}
@keyframes ns-stitch-settle{from{opacity:0;transform:translateY(-3px) scale(0.9);}to{opacity:1;transform:none;}}
@media (prefers-reduced-motion: reduce){
.ns-stitch-thread{transition:opacity 0ms;}
.ns-stitch-thread text{transition:stroke-opacity 120ms linear;}
.ns-stitch-pile{animation:none;}
}
.ns-stitch-svg text{paint-order:stroke;}
`;
a hero or section headline where letter-by-letter unraveling under the cursor is the point — a craft/textile/handmade brand story, a playful 404 or empty state. Reach for text-ligature-melt instead when the effect you want is glyphs gooily fusing and swelling near the cursor rather than a stitched outline coming apart thread by thread — the two never share a visual vocabulary (fill vs. stroke, blur vs. dangle).
Build <StitchPick text? size? dwellMs? className?>, a headline rendered as dashed-stroke, fill:none SVG <text> glyphs ("running stitch") that a passing cursor picks apart letter by letter. LAYOUT: lay every glyph on a monospace grid (fontFamily var(--font-mono), fontWeight 700) so per-letter hit-testing is index math, not proportional-width measurement: render one throwaway offscreen <text>M</text> once, read its getComputedTextLength() in an effect (re-run once more after document.fonts.ready resolves, in case the mono face swaps in after first paint) to get the true per-glyph advance in px, then position glyph i at x = i * advance. THREAD RENDERING: each glyph is TWO offset <text> elements, both fill="none" stroke-dasharray="3 2.4" (the running-stitch dash), one full-strength stroke=var(--foreground) strokeWidth 1.6 slightly on-baseline, one stroke=var(--accent) strokeOpacity ~0.22 strokeWidth 1 offset +0.6/-0.6px — the second, low-alpha, off-hue pass is the loud collection's subtle two-tone shimmer, not a wash. STATE MACHINE, per letter, independent of every other letter: stitched (default: both dashed strokes visible) -> pointer enters that letter's horizontal zone (glyph index = floor((clientX - svgLeft) / advance), computed once from a single onPointerMove on the SVG root rather than per-glyph listeners) -> picked: the stitched <g> fades to opacity 0 over 200ms and a dangling thread <path> appears, driven by a 4-point verlet chain (point 0 pinned at the glyph's top-stitch anchor; points 1-3 integrate gravity 0.055 + a small per-letter-seeded sin sway + velocity damping 0.96, then 3 iterations of a distance constraint at segment length 4.2, all inside a SHARED requestAnimationFrame loop that starts when the first letter is picked and stops itself the frame no letter is left in the picked state — every frame writes the path's `d` attribute directly via a ref, never through React state). If the pointer leaves that letter's zone before `dwellMs` (default 650) elapses, the letter returns straight to stitched (dashed <g> fades back in, a `transition-delay: index*18ms` stagger so a multi-letter re-stitch reads as sewn back in left-to-right order rather than snapping at once) and the dwell timer is cleared. If the pointer stays past dwellMs, the letter advances to unraveled: physics stops, the dangling path is replaced by a small static wavy "pile" shape (a couple of quadratic-bezier loops near the glyph baseline, same dashed thread styling) that plays one 220ms settle-in (opacity+translateY) and then just sits there — it does NOT auto re-stitch on pointer leave anymore. UNRAVELING RESOLUTION: clicking directly on an unraveled letter (same index math, on a root onClick) resews just that letter; a real, always-rendered <button> below the headline, labeled "Re-sew" (or "Re-sew (nothing loose)" when nothing is unraveled, so its accessible name always reflects real state and it is never a no-op the user can't discover) resews every unraveled letter at once — this button is also the component's one required real interactive control for keyboard/screen-reader users, since the letter-hover/click interactions are a mouse-only bonus layer on top of it, not a replacement for it. A11Y: the SVG itself is role="img" aria-label={text} (the individual glyph strokes are aria-hidden, so a screen reader gets the plain headline text once, not per-letter noise); the Re-sew button is a real focusable <button> with a visible focus-visible ring. REDUCED MOTION: the entire picked/unraveled state machine is bypassed — onPointerMove instead just tracks a single "brightened" letter index and bumps that letter's stroke-opacity (foreground 0.85->1, accent 0.22->0.4) over a fast 120ms linear color transition, no dangling, no physics, no pile, so hover still visibly differs from rest with zero motion. TOKENS: var(--foreground) for the primary thread, var(--accent) at low opacity for the shimmer pass, var(--border) for the Re-sew button's chrome — no fill color anywhere on the glyphs themselves (stroke only is the whole point of the embroidery look). Not SVG <path> pathLength/dash-window tricks anywhere — the dasharray here is a static texture, not a normalized progress indicator, so the pathLength+non-scaling-stroke screen-space trap does not apply, but is avoided regardless as a matter of course. DEMO: a centered headline ("UNRAVEL") self-driving through the whole state space unattended — a quick multi-letter sweep (pick+auto-restitch), a long dwell on one letter (unravels, then a synthetic click resews it), dwells on two more letters followed by a synthetic click on the Re-sew button — all via real dispatched PointerEvent/click sequences at the SVG's DOM node, not simulated state.