{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-progress","type":"registry:component","title":"Text progress","description":"Fills the label itself with ink as the upload runs.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/text-progress.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { motion, useSpring, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\nconst DIGITS = [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"];\n// The tens column starts blank, so 7% never reads as 07%.\nconst TENS = [\" \", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"];\nconst EASE_OUT = \"ease-[cubic-bezier(0.23,1,0.32,1)]\";\n\nfunction Roll({ strip, index }: { strip: string[]; index: number }) {\n  return (\n    <span className=\"relative inline-block h-[1lh] w-[1ch] overflow-hidden\">\n      <span\n        className={cn(\n          \"absolute inset-x-0 top-0 flex flex-col transition-[translate] duration-300 motion-reduce:transition-none\",\n          EASE_OUT,\n        )}\n        style={{ translate: `0 ${-index * 10}%` }}\n      >\n        {strip.map((d, i) => (\n          <span key={i} className=\"h-[1lh]\">\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\nexport function TextProgress({\n  value,\n  label,\n  doneLabel = \"Done\",\n  className,\n}: {\n  /** 0 to 100. */\n  value: number;\n  label: string;\n  doneLabel?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const clamped = Math.min(100, Math.max(0, value));\n  const percent = Math.round(clamped);\n  const done = percent >= 100;\n\n  // Progress arrives in uneven jumps; a spring turns them into one steady\n  // pour of ink and keeps its speed when the next jump lands mid-glide.\n  const ink = useSpring(clamped, { stiffness: 90, damping: 20 });\n  useEffect(() => {\n    if (reduceMotion) ink.jump(clamped);\n    else ink.set(clamped);\n  }, [clamped, ink, reduceMotion]);\n  const clip = useTransform(ink, (v) => `inset(0 ${Math.max(0, 100 - v).toFixed(2)}% 0 0)`);\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={percent}\n      aria-valuetext={done ? doneLabel : `${percent}%`}\n      className={cn(\n        // Every part is exactly one 24px line tall, so centring lines them\n        // up; baseline alignment breaks on the clipped digit columns.\n        \"relative inline-flex items-center gap-2.5 text-base leading-6 font-medium\",\n        className,\n      )}\n    >\n      <span aria-hidden className=\"grid\">\n        <span\n          className={cn(\n            \"relative col-start-1 row-start-1 whitespace-nowrap transition-[opacity,filter,translate]\",\n            EASE_OUT,\n            done\n              ? \"-translate-y-1 opacity-0 blur-[4px] duration-200 motion-reduce:translate-y-0\"\n              : \"opacity-100 duration-300\",\n          )}\n        >\n          <span className=\"text-foreground/20\">{label}</span>\n          {/* The same words, stacked and clipped: ink fills the letters\n              themselves rather than a bar beside them. */}\n          <motion.span\n            className=\"absolute inset-0 text-foreground\"\n            style={{ clipPath: clip }}\n          >\n            {label}\n          </motion.span>\n        </span>\n        <span\n          className={cn(\n            \"col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap text-foreground transition-[opacity,filter,translate]\",\n            EASE_OUT,\n            done\n              ? \"translate-y-0 opacity-100 filter-none delay-100 duration-300\"\n              : \"pointer-events-none translate-y-1 opacity-0 blur-[4px] duration-150 motion-reduce:translate-y-0\",\n          )}\n        >\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 shrink-0\">\n            <path\n              d=\"m3.5 8.5 3 3 6-7\"\n              pathLength={1}\n              stroke=\"currentColor\"\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeDasharray={1}\n              className={cn(\n                \"transition-[stroke-dashoffset] ease-[cubic-bezier(0.65,0,0.35,1)] motion-reduce:transition-none\",\n                // Draws once the word has arrived; rewinds only after it's gone.\n                done ? \"[stroke-dashoffset:0] delay-150 duration-300\" : \"[stroke-dashoffset:1] delay-150 duration-0\",\n              )}\n            />\n          </svg>\n          {doneLabel}\n        </span>\n      </span>\n\n      <span\n        aria-hidden\n        className={cn(\n          \"flex text-muted-foreground tabular-nums transition-[opacity,filter]\",\n          EASE_OUT,\n          done ? \"opacity-0 blur-[4px] duration-200\" : \"opacity-100 duration-300\",\n        )}\n      >\n        <Roll strip={TENS} index={Math.floor((percent % 100) / 10)} />\n        <Roll strip={DIGITS} index={percent % 10} />\n        <span>%</span>\n      </span>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {done ? doneLabel : \"\"}\n      </span>\n    </div>\n  );\n}\n\n// An index card at rest holds the upload part-way, so the half-inked label\n// shows the idea without anything running.\nconst PREVIEW_REST = 62;\n\nexport default function TextProgressDemo() {\n  const play = usePreviewPlay();\n  const [value, setValue] = useState(() => (play === null ? 0 : PREVIEW_REST));\n  const [run, setRun] = useState(0);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  // A fake upload: uneven chunks at uneven intervals, like a real network.\n  useEffect(() => {\n    if (play !== null) return;\n    let v = 0;\n    const tick = () => {\n      v = Math.min(100, v + 4 + Math.random() * 14);\n      setValue(v);\n      if (v < 100) timer.current = setTimeout(tick, 220 + Math.random() * 480);\n    };\n    timer.current = setTimeout(tick, 600);\n    return () => clearTimeout(timer.current);\n  }, [run, play]);\n\n  // Index preview: the waiting upload picks up and finishes, holds on\n  // \"Uploaded\" for a beat, then the next one starts from empty. Leaving\n  // glides the ink back to where it rested.\n  useEffect(() => {\n    if (!play) return;\n    let v = PREVIEW_REST;\n    let t: ReturnType<typeof setTimeout>;\n    const tick = () => {\n      v = Math.min(100, v + 5 + Math.random() * 12);\n      setValue(v);\n      if (v < 100) {\n        t = setTimeout(tick, 200 + Math.random() * 350);\n        return;\n      }\n      t = setTimeout(() => {\n        v = 0;\n        setValue(0);\n        t = setTimeout(tick, 900);\n      }, 1800);\n    };\n    t = setTimeout(tick, 250);\n    return () => {\n      clearTimeout(t);\n      setValue(PREVIEW_REST);\n    };\n  }, [play]);\n\n  return (\n    <div className=\"flex flex-col items-center gap-5\">\n      <TextProgress value={value} label=\"Uploading 3 files\" doneLabel=\"Uploaded\" />\n      <button\n        type=\"button\"\n        onClick={() => {\n          setValue(0);\n          setRun((r) => r + 1);\n        }}\n        className=\"flex h-8 items-center gap-1.5 rounded-full px-3 text-[13px] font-medium text-muted-foreground outline-hidden transition-[color,background-color,scale] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n      >\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-3.5\">\n          <path\n            d=\"M2.75 8a5.25 5.25 0 1 0 1.54-3.71M2.75 2.5v2.25H5\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n        Restart\n      </button>\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"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/text-progress"}