{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"upload-button","type":"registry:component","title":"Upload button","description":"Shrinks into a progress ring, fills, then checks off.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/upload-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, useMotionValueEvent, useSpring, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type UploadStatus = \"idle\" | \"uploading\" | \"done\";\n\n// The circle the pill collapses into, matching the pill's h-10.\nconst SIZE = 40;\n// Sits 1px inside the edge so the 2px stroke never touches the shadow ring.\nconst RADIUS = 18;\nconst CIRCUMFERENCE = 2 * Math.PI * RADIUS;\n// No bounce: a width that overshoots would read as the button wobbling.\nconst MORPH = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Progress events arrive every few hundred ms; this glides between them\n// without trailing so far behind that the ring feels dishonest.\nconst FILL = { visualDuration: 0.4, bounce: 0 };\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Close enough to full that the check never waits on the spring's tail.\nconst FULL = 0.995;\n\nexport function UploadButton({\n  status,\n  progress,\n  onStart,\n  onCancel,\n  onReset,\n  label = \"Upload\",\n  // Long enough to register the check, short enough to be ready again.\n  resetAfter = 1500,\n  className,\n}: {\n  status: UploadStatus;\n  /** 0 to 1. Only read while uploading. */\n  progress: number;\n  onStart: () => void;\n  onCancel: () => void;\n  /** Called `resetAfter` ms after the check appears; set status back to idle. */\n  onReset: () => void;\n  label?: string;\n  resetAfter?: number;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const shown = useSpring(0, FILL);\n  const [ringFull, setRingFull] = useState(false);\n  const [note, setNote] = useState(\"\");\n  const prevStatus = useRef<UploadStatus>(status);\n\n  useEffect(() => {\n    const wasActive = prevStatus.current !== \"idle\";\n    prevStatus.current = status;\n    // Idle keeps the last value so a cancelled ring fades out where it was.\n    if (status === \"idle\") return;\n    // A fresh upload starts empty rather than sweeping back from 100%.\n    if (!wasActive) shown.jump(0);\n    const target = status === \"done\" ? 1 : Math.min(Math.max(progress, 0), 1);\n    if (reduceMotion) shown.jump(target);\n    else shown.set(target);\n  }, [status, progress, reduceMotion, shown]);\n\n  useMotionValueEvent(shown, \"change\", (v) => setRingFull(v >= FULL));\n\n  // The check waits for the ring to visibly close, not just for the status.\n  const checked = status === \"done\" && ringFull;\n\n  useEffect(() => {\n    if (!checked) return;\n    const timer = setTimeout(onReset, resetAfter);\n    return () => clearTimeout(timer);\n  }, [checked, onReset, resetAfter]);\n\n  const dashOffset = useTransform(shown, (v) => CIRCUMFERENCE * (1 - v));\n  // A round cap at 0% would draw a lone dot at 12 o'clock.\n  const arcOpacity = useTransform(shown, [0, 0.02], [0, 1]);\n  const percent = useTransform(shown, (v) => Math.round(v * 100));\n\n  const compact = status !== \"idle\";\n  const uploading = status === \"uploading\";\n  const ringVisible = uploading || (status === \"done\" && !checked);\n\n  const click = () => {\n    if (status === \"uploading\") {\n      setNote(\"Upload cancelled\");\n      onCancel();\n    } else if (status === \"idle\") {\n      setNote(\"\");\n      onStart();\n    }\n  };\n\n  const name =\n    status === \"uploading\"\n      ? \"Cancel upload\"\n      : status === \"done\"\n        ? \"Upload complete\"\n        : label;\n\n  return (\n    // Press scale lives here (:active also matches ancestors) so the ring,\n    // which sits outside the button for ARIA reasons, shrinks with it.\n    <span\n      className={cn(\n        \"relative inline-flex transition-[scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-none\",\n        className,\n      )}\n    >\n      <motion.button\n        type=\"button\"\n        aria-label={name}\n        aria-busy={uploading}\n        aria-disabled={status === \"done\" || undefined}\n        onClick={click}\n        initial={false}\n        // Width, not scale, so nothing inside ever stretches; each state's\n        // content is its own layer that fades instead.\n        animate={{ width: compact ? SIZE : \"auto\" }}\n        transition={reduceMotion ? { duration: 0 } : MORPH}\n        className={cn(\n          \"group relative inline-flex h-10 touch-manipulation items-center justify-center overflow-hidden rounded-full text-sm font-medium shadow-raised outline-hidden select-none\",\n          \"transition-[background-color,color] duration-200 ease-out\",\n          \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          checked ? \"bg-foreground text-background\" : \"bg-muted text-foreground\",\n          status === \"done\" && \"cursor-default\",\n        )}\n      >\n        {/* One grid cell: the pill layer sets the auto width, the others\n            center on top of it and overflow evenly when compact. */}\n        <span className=\"grid\" aria-hidden>\n          <Layer\n            visible={status === \"idle\"}\n            // Waits for the pill to open most of the way before the label\n            // resolves, so text never shows up clipped by a narrow pill.\n            enterDelay\n            // Icon side gets 2px less: the glyph's own whitespace fills it.\n            className=\"gap-2 pr-4 pl-3.5 whitespace-nowrap\"\n          >\n            <svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-4 shrink-0\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path d=\"M8 10.25v-7.5M4.75 6 8 2.75 11.25 6M2.75 10.5v1.25c0 .83.67 1.5 1.5 1.5h7.5c.83 0 1.5-.67 1.5-1.5V10.5\" />\n            </svg>\n            {label}\n          </Layer>\n\n          {/* Percent by default; hover or keyboard focus swaps it for a stop\n              mark so the cancel action is discoverable. */}\n          <Layer\n            // Holds through the ring's last stretch so it reads 100 before\n            // the check replaces it.\n            visible={ringVisible}\n            className={cn(\n              \"text-[11px] tabular-nums\",\n              uploading &&\n                \"group-hover:opacity-0 group-hover:blur-[4px] group-focus-visible:opacity-0 group-focus-visible:blur-[4px]\",\n            )}\n          >\n            <motion.span>{percent}</motion.span>\n          </Layer>\n          <Layer\n            visible={false}\n            className={cn(\n              uploading &&\n                \"group-hover:translate-y-0 group-hover:opacity-100 group-hover:blur-[0px] group-focus-visible:translate-y-0 group-focus-visible:opacity-100 group-focus-visible:blur-[0px]\",\n            )}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-3\" fill=\"currentColor\">\n              <rect x=\"3\" y=\"3\" width=\"10\" height=\"10\" rx=\"2\" />\n            </svg>\n          </Layer>\n\n          <span className=\"col-start-1 row-start-1 flex items-center justify-center\">\n            <motion.svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-4\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={false}\n              animate={\n                checked\n                  ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n                  : reduceMotion\n                    ? { scale: 1, opacity: 0, filter: \"blur(0px)\" }\n                    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n              }\n              transition={ICON_SWAP}\n            >\n              <path d=\"m3.5 8.5 3 3 6-7\" />\n            </motion.svg>\n          </span>\n        </span>\n      </motion.button>\n\n      {/* Outside the button: a button's children are presentational, so a\n          progressbar nested inside would never reach assistive tech. */}\n      <span\n        role=\"progressbar\"\n        aria-label=\"Upload progress\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={uploading ? Math.round(progress * 100) : undefined}\n        aria-hidden={!uploading}\n        className={cn(\n          \"pointer-events-none absolute top-0 left-1/2 size-10 -translate-x-1/2 transition-[opacity] ease-out\",\n          ringVisible ? \"opacity-100 duration-150\" : \"opacity-0 duration-200\",\n        )}\n      >\n        {/* -90deg moves the stroke's start from 3 o'clock to 12. */}\n        <svg\n          viewBox={`0 0 ${SIZE} ${SIZE}`}\n          className=\"size-full -rotate-90\"\n          fill=\"none\"\n          strokeWidth={2}\n        >\n          <circle\n            cx={SIZE / 2}\n            cy={SIZE / 2}\n            r={RADIUS}\n            className=\"stroke-foreground/10\"\n          />\n          <motion.circle\n            cx={SIZE / 2}\n            cy={SIZE / 2}\n            r={RADIUS}\n            className=\"stroke-foreground\"\n            strokeLinecap=\"round\"\n            strokeDasharray={CIRCUMFERENCE}\n            style={{ strokeDashoffset: dashOffset, opacity: arcOpacity }}\n          />\n        </svg>\n      </span>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {uploading ? \"Uploading\" : status === \"done\" ? \"Upload complete\" : note}\n      </span>\n    </span>\n  );\n}\n\n// Enters over 200ms, leaves in 100ms: the outgoing state should be gone\n// before the width morph gets far enough to clip it.\nfunction Layer({\n  visible,\n  enterDelay,\n  className,\n  children,\n}: {\n  visible: boolean;\n  enterDelay?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      className={cn(\n        \"col-start-1 row-start-1 flex items-center justify-center transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n        visible\n          ? cn(\n              \"translate-y-0 opacity-100 blur-[0px] duration-200\",\n              enterDelay && \"delay-100\",\n            )\n          : \"translate-y-0.5 opacity-0 blur-[4px] duration-100 motion-reduce:translate-y-0 motion-reduce:blur-[0px]\",\n        className,\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\n// Fast start, a stall near the middle, then a quick finish: roughly how a\n// real upload looks. [ms from start, progress]\nconst STEPS: [number, number][] = [\n  [120, 0.12],\n  [260, 0.27],\n  [420, 0.41],\n  [580, 0.5],\n  [1200, 0.54],\n  [1500, 0.61],\n  [1720, 0.74],\n  [1900, 0.83],\n  [2120, 0.92],\n  [2450, 1],\n];\n\nexport default function UploadButtonDemo() {\n  const [status, setStatus] = useState<UploadStatus>(\"idle\");\n  const [progress, setProgress] = useState(0);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  const clear = () => {\n    timers.current.forEach(clearTimeout);\n    timers.current = [];\n  };\n\n  useEffect(() => clear, []);\n\n  const start = () => {\n    clear();\n    setProgress(0);\n    setStatus(\"uploading\");\n    for (const [at, value] of STEPS) {\n      // Up to 80ms of jitter (less than any gap between steps, so order\n      // holds) keeps repeat runs from feeling canned.\n      const jitter = value === 1 ? 0 : Math.random() * 80;\n      timers.current.push(\n        setTimeout(() => {\n          setProgress(value);\n          if (value === 1) setStatus(\"done\");\n        }, at + jitter),\n      );\n    }\n  };\n\n  const cancel = () => {\n    clear();\n    setStatus(\"idle\");\n  };\n\n  const reset = useCallback(() => setStatus(\"idle\"), []);\n\n  return (\n    <UploadButton\n      status={status}\n      progress={progress}\n      onStart={start}\n      onCancel={cancel}\n      onReset={reset}\n    />\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":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/upload-button"}