Skip to main content

ns-ui / loud

Text Stitch Unpick

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.

Usage

registry/loud/text-stitch-unpick/demo.tsx
"use client";

import { useEffect, useRef } from "react";
import { StitchPick } from "./component";

const TEXT = "UNRAVEL";

function dispatchPointer(el: Element, type: string, clientX: number, clientY: number) {
  el.dispatchEvent(
    new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, clientX, clientY })
  );
}

async function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function letterX(rect: DOMRect, index: number) {
  const advance = rect.width / TEXT.length;
  return rect.left + advance * (index + 0.5);
}

// Self-driving tour of the whole state machine: a quick sweep (pick + auto
// re-stitch on each letter), a long dwell on one letter (fully unravels into
// a pile), a click on that letter (resews it), two more dwells followed by
// clicking the "Re-sew" button (resews everything at once).
async function runScript(root: HTMLElement, cancelled: () => boolean) {
  const svg = root.querySelector("svg");
  if (!svg) return;
  const rect = svg.getBoundingClientRect();
  const y = rect.top + rect.height * 0.55;

  // quick sweep across the first few letters
  for (let i = 0; i < 4 && !cancelled(); i++) {
    dispatchPointer(svg, "pointermove", letterX(rect, i), y);
    await wait(160);
  }
  dispatchPointer(svg, "pointerleave", letterX(rect, 4), y);
  await wait(500);
  if (cancelled()) return;

  // dwell on letter 1 until it unravels, then click to resew it
  dispatchPointer(svg, "pointermove", letterX(rect, 1), y);
  await wait(900);
  if (cancelled()) return;
  dispatchPointer(svg, "click", letterX(rect, 1), y);
  await wait(400);
  dispatchPointer(svg, "pointerleave", letterX(rect, 1), y);
  await wait(600);
  if (cancelled()) return;

  // dwell on two letters, then resew everything via the button
  dispatchPointer(svg, "pointermove", letterX(rect, 2), y);
  await wait(900);
  if (cancelled()) return;
  dispatchPointer(svg, "pointermove", letterX(rect, 5), y);
  await wait(900);
  dispatchPointer(svg, "pointerleave", letterX(rect, 5), y);
  if (cancelled()) return;
  await wait(500);

  const resewBtn = root.querySelector<HTMLButtonElement>(".ns-stitch-resew");
  resewBtn?.click();
  await wait(1400);
}

export default function StitchPickDemo() {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const cancelledRef = useRef(false);

  useEffect(() => {
    cancelledRef.current = false;
    let timer: ReturnType<typeof setTimeout> | undefined;

    async function loop() {
      while (!cancelledRef.current) {
        if (rootRef.current) {
          await runScript(rootRef.current, () => cancelledRef.current);
        }
        await wait(700);
      }
    }
    timer = setTimeout(loop, 700);
    return () => {
      cancelledRef.current = true;
      clearTimeout(timer);
    };
  }, []);

  return (
    <div ref={rootRef} className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / text-stitch-unpick</p>

      <StitchPick text={TEXT} />

      <p className="max-w-md text-center text-xs text-ns-muted">
        Pass the seam ripper over a letter to pick its stitches loose; linger
        and it fully unravels into a pile you have to re-sew.
      </p>
    </div>
  );
}

Component

registry/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 {
  /** the rendered text */
  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;
  /** extra classes merged onto the rendered root element */
  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(--ns-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(--ns-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-ns-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;}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/text-stitch-unpick.tsx, so point the import there.

Use when 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).

Install

npx shadcn add https://design.helpmarq.com/r/text-stitch-unpick.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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(--ns-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(--ns-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.

Props

PropTypeDefaultDescription
text?string"UNRAVEL"the rendered text
size?number56Glyph font size in px. Default 56.
dwellMs?number650Hover dwell before a picked letter fully unravels into a pile, ms. Default 650.
className?string—extra classes merged onto the rendered root element