{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"footer-signature","type":"registry:component","title":"Footer signature","description":"Signs the footer by hand when it scrolls into view, slowing through every loop.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/footer-signature.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: the pen's speed comes from the path itself. The path\n   is sampled once, each sample gets a cost from how sharply the line turns\n   there, and time is spent in proportion to that cost. So the pen races\n   down straight strokes and slows through loops, the way a hand does,\n   without any hand-made keyframes. The ink is three copies of the same\n   stroke nudged along a slanted nib, which makes strokes heavier in one\n   direction than the other, like a broad-edged pen. */\n\n// Handwritten \"xevrion\": one continuous stroke through the letters and a\n// return swash underneath, then the pen lifts once to dot the i, the way\n// people really sign. Drawn upright and slanted 12 degrees, in the default\n// viewBox below.\nexport const XEVRION_SIGNATURE =\n  \"M 24 60 C 31 50 47 50 46 60 C 46 70 40 80 30 80 C 22 80 22 72 28 72 C 38 70 54 60 70 54 \" +\n  \"C 62 52 50 60 50 70 C 50 80 60 82 68 78 C 77 74 89 66 90 60 C 92 54 84 54 80 62 \" +\n  \"C 76 72 80 82 92 78 C 100 72 104 60 108 54 C 108 64 107 74 110 80 C 116 72 124 60 130 54 \" +\n  \"C 133 50 127 50 126 56 C 126 60 132 60 139 58 C 141 56 143 52 145 47 C 146 52 146 56 150 55 \" +\n  \"C 153 54 154 52 157 52 C 153 62 151 72 151 80 C 156 74 163 62 167 54 C 164 64 160 76 165 80 \" +\n  \"C 173 76 191 58 202 54 C 193 50 182 62 182 72 C 182 82 194 82 200 72 C 205 64 204 54 200 54 \" +\n  \"C 205 58 211 58 217 56 C 222 54 220 64 214 80 C 221 66 232 54 237 56 C 243 58 238 72 238 80 \" +\n  \"C 241 86 254 80 264 70 C 286 52 268 100 206 100 C 156 100 105 102 54 108 \" +\n  \"M 171 41 C 172 40 174 39 175 39\";\n\n// The whole signature takes this long: a quick, practised signature. Past\n// the usual 300ms UI budget on purpose, since it is handwriting that plays\n// once, and any faster the slowdown through loops stops reading.\nconst DURATION = 700;\n// How strongly a turn slows the pen. At 0 the pen moves at constant speed;\n// around 10 the loops take roughly three times longer than the straights.\nconst CURVE_DRAG = 10;\n// Samples along the path. Enough that the cost curve is smooth at 2x zoom.\nconst SAMPLES = 360;\n// The slant of the nib, in path units: each copy of the stroke is shifted\n// this much further up and to the right.\nconst NIB_STEP = 1.4;\nconst NIB_COPIES = 3;\n\ntype Timing = { lengths: Float32Array; times: Float32Array };\n\nfunction buildTiming(path: SVGPathElement): Timing {\n  const total = path.getTotalLength();\n  const pts = Array.from({ length: SAMPLES + 1 }, (_, i) => path.getPointAtLength((total * i) / SAMPLES));\n  const lengths = new Float32Array(SAMPLES + 1);\n  const times = new Float32Array(SAMPLES + 1);\n  let t = 0;\n  for (let i = 1; i <= SAMPLES; i++) {\n    lengths[i] = (total * i) / SAMPLES;\n    const a = pts[Math.max(0, i - 2)];\n    const b = pts[i - 1];\n    const c = pts[i];\n    const turn = Math.abs(\n      Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x),\n    );\n    // A jump to a new subpath reads as a sharp turn too, so a pen lift\n    // gets a natural beat of stillness for free.\n    const bend = Math.min(turn, 2 * Math.PI - turn);\n    t += 1 + CURVE_DRAG * bend;\n    times[i] = t;\n  }\n  for (let i = 0; i <= SAMPLES; i++) times[i] /= t;\n  return { lengths, times };\n}\n\n// Time is normalized 0..1; returns the path length drawn by then.\nfunction lengthAt({ lengths, times }: Timing, time: number) {\n  let lo = 0;\n  let hi = times.length - 1;\n  while (hi - lo > 1) {\n    const mid = (lo + hi) >> 1;\n    if (times[mid] < time) lo = mid;\n    else hi = mid;\n  }\n  const span = times[hi] - times[lo] || 1;\n  return lengths[lo] + ((time - times[lo]) / span) * (lengths[hi] - lengths[lo]);\n}\n\n// A gentle start and a slightly quicker finish, like setting the pen down\n// and flicking it off the page.\nconst penEase = (x: number) => (x < 0.08 ? (x / 0.08) ** 2 * 0.04 : 0.04 + (x - 0.08) * (0.96 / 0.92));\n\nexport type FooterLink = { label: string; href: string };\n\nexport function FooterSignature({\n  name,\n  signature = XEVRION_SIGNATURE,\n  viewBox = \"12 30 282 86\",\n  tagline,\n  columns,\n  copyright,\n  scrollRef,\n  replayKey = 0,\n  className,\n}: {\n  name: string;\n  // An SVG path, ideally one continuous stroke. Extra subpaths (an i dot,\n  // a t cross) are written last, after a short pause for the pen lift.\n  signature?: string;\n  viewBox?: string;\n  tagline?: string;\n  columns: { title: string; links: FooterLink[] }[];\n  copyright: string;\n  /* The scrolling element the footer lives in. Leave it out for a normal\n     page, where the window scrolls. */\n  scrollRef?: React.RefObject<HTMLElement | null>;\n  // Change it to write the signature again next time the footer is in view.\n  replayKey?: number;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const root = useRef<HTMLElement>(null);\n  const measure = useRef<SVGPathElement>(null);\n  const strokes = useRef<(SVGPathElement | null)[]>([]);\n  const [phase, setPhase] = useState<\"idle\" | \"writing\" | \"done\">(\"idle\");\n  // Split at every absolute moveto. Write extra subpaths with \"M\", not \"m\".\n  const subpaths = useMemo(() => signature.split(/(?=M)/).map((d) => d.trim()).filter(Boolean), [signature]);\n\n  useEffect(() => {\n    const el = root.current;\n    const path = measure.current;\n    if (!el || !path) return;\n    const total = path.getTotalLength();\n    const parts = strokes.current.slice(0, subpaths.length).map((s) => s?.getTotalLength() ?? 0);\n    const starts = parts.map((_, k) => parts.slice(0, k).reduce((a, b) => a + b, 0));\n    const setDrawn = (len: number) => {\n      strokes.current.forEach((s, i) => {\n        if (!s) return;\n        const k = i % subpaths.length;\n        const part = parts[k];\n        const drawn = Math.min(part, Math.max(0, len - starts[k]));\n        s.style.strokeDasharray = `${part} ${part + 1}`;\n        s.style.strokeDashoffset = String(part - drawn);\n      });\n    };\n    setDrawn(0);\n    setPhase(\"idle\");\n\n    let frame = 0;\n    let start = 0;\n    let timing: Timing | null = null;\n    let finished = false;\n\n    const tick = (now: number) => {\n      if (!timing) return;\n      if (!start) start = now;\n      const p = Math.min(1, (now - start) / DURATION);\n      setDrawn(lengthAt(timing, penEase(p)));\n      if (p < 1) {\n        frame = requestAnimationFrame(tick);\n      } else {\n        finished = true;\n        frame = 0;\n        setPhase(\"done\");\n      }\n    };\n\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting || finished || frame || pausedAt) return;\n        if (reduceMotion) {\n          finished = true;\n          setDrawn(total);\n          setPhase(\"done\");\n          return;\n        }\n        timing ??= buildTiming(path);\n        setPhase(\"writing\");\n        frame = requestAnimationFrame(tick);\n      },\n      // Most of the footer has to be on screen, or the signature would be\n      // half written before anyone looks at it.\n      { root: scrollRef?.current ?? null, threshold: 0.6 },\n    );\n    io.observe(el);\n\n    // Pause with the tab; resume from the same point rather than skipping.\n    let pausedAt = 0;\n    const onVisibility = () => {\n      if (!frame && !pausedAt) return;\n      if (document.hidden && frame) {\n        cancelAnimationFrame(frame);\n        frame = 0;\n        pausedAt = performance.now();\n      } else if (!document.hidden && pausedAt) {\n        start += performance.now() - pausedAt;\n        pausedAt = 0;\n        frame = requestAnimationFrame(tick);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      io.disconnect();\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [subpaths, scrollRef, replayKey, reduceMotion]);\n\n  const done = phase === \"done\";\n  // The printed name and tagline under the signature settle in once the pen\n  // lifts, like a caption. The links stay put the whole time: they are the\n  // footer's job, and never wait on an animation.\n  const settle = (i: number) =>\n    cn(\n      \"transition-[opacity,translate,filter] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0\",\n      done\n        ? \"translate-y-0 opacity-100 blur-none duration-300\"\n        : \"translate-y-1.5 opacity-0 blur-[4px] duration-0\",\n      done && [\"delay-0\", \"delay-[60ms]\"][i],\n    );\n\n  return (\n    <footer ref={root} className={cn(\"text-[14px] text-muted-foreground\", className)}>\n      <div className=\"flex flex-wrap items-start justify-between gap-x-10 gap-y-6\">\n        <div className=\"min-w-0\">\n          <svg\n            viewBox={viewBox}\n            role=\"img\"\n            aria-label={`${name}'s signature`}\n            className=\"h-10 w-auto max-w-full overflow-visible text-foreground\"\n          >\n            <path ref={measure} d={signature} fill=\"none\" stroke=\"none\" />\n            {Array.from({ length: NIB_COPIES }, (_, copy) => (\n              <g key={copy} transform={`translate(${copy * NIB_STEP} ${-copy * NIB_STEP})`}>\n                {/* One element per subpath: browsers restart the dash at\n                    every moveto, so a single path would show the i dot\n                    before the pen ever got there. */}\n                {subpaths.map((d, k) => (\n                  <path\n                    key={k}\n                    ref={(el) => {\n                      strokes.current[copy * subpaths.length + k] = el;\n                    }}\n                    data-subpath={k}\n                    d={d}\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2.6}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    // Hidden until the first frame sets the dash, so nothing\n                    // flashes fully drawn before JavaScript runs.\n                    style={{ strokeDasharray: \"0 1e4\" }}\n                  />\n                ))}\n              </g>\n            ))}\n          </svg>\n          <p className={cn(\"mt-2 font-medium text-foreground\", settle(0))}>{name}</p>\n          {tagline && <p className={cn(\"mt-0.5\", settle(1))}>{tagline}</p>}\n        </div>\n\n        <div className=\"flex gap-10\">\n          {columns.map((col) => (\n            <nav key={col.title} aria-label={`${name}: ${col.title}`}>\n              <p className=\"text-[13px] font-medium text-foreground\">{col.title}</p>\n              <ul className=\"mt-2 flex flex-col\">\n                {col.links.map((l) => (\n                  <li key={l.label}>\n                    <a\n                      href={l.href}\n                      className=\"-mx-1 inline-flex h-8 items-center rounded-md px-1 outline-hidden transition-[color] duration-150 ease-out hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n                    >\n                      {l.label}\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            </nav>\n          ))}\n        </div>\n      </div>\n      <div\n        className=\"mt-8 flex flex-wrap justify-between gap-2 border-t border-border pt-4 text-[13px]\"\n      >\n        <span>{copyright}</span>\n        <span>Signed, not generated.</span>\n      </div>\n    </footer>\n  );\n}\n\n/* A scrolling page mock that ends in the footer. */\n\nconst COLUMNS = [\n  {\n    title: \"Work\",\n    links: [\n      { label: \"Projects\", href: \"#\" },\n      { label: \"Lab\", href: \"#\" },\n      { label: \"Resume\", href: \"#\" },\n    ],\n  },\n  {\n    title: \"Elsewhere\",\n    links: [\n      { label: \"GitHub\", href: \"#\" },\n      { label: \"X\", href: \"#\" },\n      { label: \"Email\", href: \"#\" },\n    ],\n  },\n];\n\nconst POSTS = [\n  { title: \"Building a physics engine for a car simulator\", meta: \"Aug 2026 · 12 min\" },\n  { title: \"What I learned shipping 91 components in a day\", meta: \"Sep 2026 · 6 min\" },\n  { title: \"Reverse engineering my keyboard's config protocol\", meta: \"Jun 2026 · 9 min\" },\n  { title: \"Fedora, niri and an Nvidia card: a field guide\", meta: \"Jul 2026 · 8 min\" },\n];\n\nexport default function FooterSignatureDemo() {\n  const scroller = useRef<HTMLDivElement>(null);\n  const [replay, setReplay] = useState(0);\n  const reduceMotion = useReducedMotion();\n\n  // The demo opens at the end of the page, so the footer is in view and\n  // signs itself straight away. Layout effects run before the footer's\n  // observer is set up, so it already sees the footer on screen.\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, []);\n\n  const onReplay = () => {\n    const el = scroller.current;\n    if (el) el.scrollTo({ top: el.scrollHeight, behavior: reduceMotion ? \"auto\" : \"smooth\" });\n    setReplay((r) => r + 1);\n  };\n\n  return (\n    <div className=\"flex h-[480px] w-[560px] max-w-full flex-col overflow-hidden rounded-2xl bg-muted shadow-raised\">\n      <div className=\"flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border pr-2 pl-5\">\n        <span className=\"truncate font-mono text-[13px] text-muted-foreground\">xevrion.dev/writing</span>\n        <button\n          type=\"button\"\n          onClick={onReplay}\n          className=\"flex h-9 touch-manipulation items-center gap-1.5 rounded-full px-3.5 text-[13px] font-medium text-foreground outline-hidden transition-[scale,background-color] duration-150 ease-out hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n        >\n          <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n            <path d=\"M2.75 8a5.25 5.25 0 1 0 1.6-3.77M2.75 2.75v2.5h2.5\" />\n          </svg>\n          Replay\n        </button>\n      </div>\n      <div\n        ref={scroller}\n        tabIndex={0}\n        aria-label=\"Page\"\n        className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain bg-background [scrollbar-width:none] outline-hidden [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\"\n      >\n        <div className=\"px-6 pt-6\">\n          <h2 className=\"text-xl font-semibold text-foreground\">Writing</h2>\n          <p className=\"mt-1 text-[15px] text-muted-foreground\">Notes on building things, mostly late at night.</p>\n          <ul className=\"mt-5 flex flex-col\">\n            {POSTS.map((p) => (\n              <li key={p.title} className=\"border-t border-border py-4\">\n                <p className=\"text-[15px] font-medium text-foreground\">{p.title}</p>\n                <p className=\"mt-1 text-[13px] text-muted-foreground\">{p.meta}</p>\n              </li>\n            ))}\n          </ul>\n          <p className=\"border-t border-border py-10 text-[15px] leading-relaxed text-pretty text-muted-foreground\">\n            Thanks for reading this far. If something here was useful, the best thanks is\n            telling me what you built with it.\n          </p>\n        </div>\n        <FooterSignature\n          name=\"xevrion\"\n          tagline=\"Design engineer. Builds things at night.\"\n          columns={COLUMNS}\n          copyright=\"© 2026 xevrion\"\n          scrollRef={scroller}\n          replayKey={replay}\n          className=\"border-t border-border bg-muted px-6 pt-8 pb-6\"\n        />\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/footer-signature"}