{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "download-button",
  "title": "Download Button",
  "description": "A circular download action whose outline becomes the progress ring, then settles into a download-again arrow with a check badge. The done state can persist, reset, or hand off to a custom action, and downloads can be cancelable.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/base/ui/download-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDownToLine, Square } from \"lucide-react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControls,\n  type Transition,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n// The glyphs are plain lucide icons, so they match the rest of a shadcn app and\n// can be swapped through props. The ring is the one drawn part, and it plays\n// three roles: the idle outline, the progress arc, and — on completion — a\n// circle that spins down into lucide's `RotateCw`. For the default done icon\n// an arrowhead rides the ring's end and grows during the spin, and the landed\n// ring *is* the icon: drawn to `RotateCw`'s geometry and kept on screen, never\n// swapped for the real one. A swap between identical drawings still shows,\n// since a separately laid-out icon lands on a half pixel and anti-aliases\n// differently. A custom done icon has no arc to land on, and a morph that\n// lands on nothing reads as broken, so that path is a plain swap: the ring\n// collapses towards the icon without spinning, and the icon sharpens out of a\n// blur on the same 0.15s–0.45s beat as the arrowhead and badge.\n\nconst VIEW_BOX = 40;\nconst CENTER = VIEW_BOX / 2;\nconst RING_RADIUS = 19;\n/** Progress starts at 12 o'clock. */\nconst RING_ROTATE = -90;\nconst SPINNER_LENGTH = 0.25;\n\n// Icons render at half the button (20px of 40px), lucide's 24px grid scaled.\nconst ICON_SIZE = 20;\nconst LUCIDE_SCALE = ICON_SIZE / 24;\n// The done icon sits up and left of centre so it and the badge balance as a\n// group; the badge overlaps the icon's lower right, where `RotateCw` is open.\n// Without the badge it moves back to the centre by `UNBADGED_SHIFT`.\nconst DONE_ICON_X = 8.5;\nconst DONE_ICON_Y = 8;\nconst UNBADGED_SHIFT = [\n  CENTER - ICON_SIZE / 2 - DONE_ICON_X,\n  CENTER - ICON_SIZE / 2 - DONE_ICON_Y,\n] as const;\n// `RotateCw`'s arc: centre (12, 12), radius 9, drawn clockwise from 3 o'clock\n// round to its arrowhead's corner at (21, 8).\nconst lucide = (x: number, y: number) =>\n  [DONE_ICON_X + x * LUCIDE_SCALE, DONE_ICON_Y + y * LUCIDE_SCALE] as const;\nconst [ARC_CX, ARC_CY] = lucide(12, 12);\nconst ARC_RADIUS = 9 * LUCIDE_SCALE;\nconst ARC_ROTATE = 0;\nconst [CORNER_X, CORNER_Y] = lucide(21, 8);\n/** Where the arc ends: the point on the circle nearest the arrowhead's corner. */\nconst ARC_END =\n  (Math.atan2(CORNER_Y - ARC_CY, CORNER_X - ARC_CX) * 180) / Math.PI + 360;\nconst ARC_LENGTH = ARC_END / 360;\n// RotateCw's arc swings slightly outward into its corner; the riding head\n// carries that offset so it lands exactly on the icon's.\nconst CORNER_OFFSET = [\n  CORNER_X - (ARC_CX + ARC_RADIUS * Math.cos((ARC_END * Math.PI) / 180)),\n  CORNER_Y - (ARC_CY + ARC_RADIUS * Math.sin((ARC_END * Math.PI) / 180)),\n] as const;\n/** The arrowhead's arms, up and left from the corner, as in `M21 3v5h-5`. */\nconst ARMS = [\n  [0, -5 * LUCIDE_SCALE],\n  [-5 * LUCIDE_SCALE, 0],\n] as const;\nconst BADGE_CX = 27.5;\nconst BADGE_CY = 27;\nconst BADGE_RADIUS = 6;\n/** Fallback in place, so the variable can be set on any ancestor. */\nconst SURFACE = \"var(--download-button-surface, var(--background))\";\n/** The gap cut around the badge where it overlaps the arc. */\nconst BADGE_GAP = 2;\n// lucide's `Check`, at 64% of the badge, centred on it.\nconst CHECK_PATH = \"M20 6 9 17l-5-5\";\nconst CHECK_SCALE = (BADGE_RADIUS * 2 * 0.64) / 24;\nconst CHECK_TRANSFORM = `translate(${BADGE_CX - 12 * CHECK_SCALE} ${BADGE_CY - 12 * CHECK_SCALE}) scale(${CHECK_SCALE})`;\n\n// Module-level so the ring effect is not restarted by every render. A strong\n// ease-out keeps the completion spin fast at first and gentle as it lands.\nconst DEFAULT_TRANSITION: Transition = {\n  duration: 0.55,\n  ease: [0.16, 1, 0.3, 1],\n};\nconst EASE_OUT: Transition[\"ease\"] = [0.215, 0.61, 0.355, 1];\n/** The riding arrowhead grows mid-spin: from 0.15s, done by 0.45s. */\nconst ARROWHEAD_GROW = {\n  duration: 0.3,\n  ease: EASE_OUT,\n  delay: 0.15,\n} satisfies Transition;\n/** A custom done icon's swap: the ring collapses while the icon blurs in. */\nconst COLLAPSE: Transition = { duration: 0.3, ease: EASE_OUT };\nconst COLLAPSE_FADE: Transition = { duration: 0.25, ease: EASE_OUT };\nconst COLLAPSE_RADIUS = ICON_SIZE * 0.45;\nconst SWAP_OUT: Transition = { duration: 0.16, ease: EASE_OUT };\nconst SWAP_IN: Transition = { ...ARROWHEAD_GROW };\n/**\n * The badge pops alongside the arrowhead and reaches full size as it finishes.\n * `visualDuration` is when a spring first arrives; the bounce settles after.\n */\nconst BADGE_POP: Transition = {\n  type: \"spring\",\n  visualDuration: ARROWHEAD_GROW.duration,\n  bounce: 0.5,\n  delay: ARROWHEAD_GROW.delay,\n};\n\n/** The arrowhead at the ring's end, rotated with it and scaled by `grow`. */\nfunction arrowheadPath([cx, cy, r, length, rotate, grow]: number[]) {\n  const end = rotate + length * 360;\n  const radians = (end * Math.PI) / 180;\n  const turn = ((end - ARC_END) * Math.PI) / 180;\n  const spin = ([x, y]: readonly number[]) => [\n    x * Math.cos(turn) - y * Math.sin(turn),\n    x * Math.sin(turn) + y * Math.cos(turn),\n  ];\n  const [ox, oy] = spin(CORNER_OFFSET);\n  const x = cx + r * Math.cos(radians) + ox;\n  const y = cy + r * Math.sin(radians) + oy;\n  const [[ax, ay], [bx, by]] = ARMS.map(spin);\n  const point = (dx: number, dy: number) =>\n    `${(x + dx * grow).toFixed(2)} ${(y + dy * grow).toFixed(2)}`;\n  return `M ${point(ax, ay)} L ${point(0, 0)} L ${point(bx, by)}`;\n}\n\nconst toPercent = (value: number) => `${(value / VIEW_BOX) * 100}%`;\nconst box = (x: number, y: number, size: number): React.CSSProperties => ({\n  left: toPercent(x),\n  top: toPercent(y),\n  width: toPercent(size),\n  height: toPercent(size),\n});\nconst ICON_STYLE = box(\n  CENTER - ICON_SIZE / 2,\n  CENTER - ICON_SIZE / 2,\n  ICON_SIZE,\n);\nconst DONE_ICON_STYLE = box(DONE_ICON_X, DONE_ICON_Y, ICON_SIZE);\nconst ICON_SLOT =\n  \"pointer-events-none absolute grid place-items-center [&_svg]:size-full\";\n\n/** Forward rotation from `from` that lands on `to` (mod 360), at least `minimum` degrees. */\nfunction forwardTo(from: number, to: number, minimum = 0) {\n  let delta = (((to - from) % 360) + 360) % 360;\n  if (delta < minimum) delta += 360;\n  return from + delta;\n}\n\nexport type DownloadButtonState = \"idle\" | \"loading\" | \"done\";\n\nexport type DownloadButtonContext = {\n  /** Aborted when the download is cancelled or the component unmounts. */\n  signal: AbortSignal;\n  /**\n   * Report progress from 0 to 1. Until the first call the ring spins\n   * indeterminately; calls after the download settles are ignored.\n   */\n  setProgress: (value: number) => void;\n};\n\nexport type DownloadButtonProps = Omit<\n  React.ComponentProps<\"button\">,\n  \"children\" | \"onError\" | \"aria-label\"\n> & {\n  /** Resolve when the download finishes; reject to return to idle. */\n  onDownload: (context: DownloadButtonContext) => void | Promise<void>;\n  /** Called when `onDownload` rejects. */\n  onError?: (error: unknown) => void;\n  /**\n   * Time in ms the done state shows before returning to idle. Omit to keep it:\n   * the done glyph already reads \"again\", so pressing it downloads again.\n   */\n  doneDuration?: number;\n  /**\n   * Replaces downloading again while done, e.g. to open the file. Pair it with\n   * a `doneLabel` and `doneIcon` that describe the new action.\n   */\n  onDoneClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  /** Icon while idle. Rendered at half the button's size. */\n  icon?: React.ReactNode;\n  /**\n   * Icon while done, beside the check badge. By default the ring spins down\n   * into lucide's `RotateCw`; another icon (e.g. `FolderOpen`, with\n   * `onDoneClick`) fades in as the ring fades out.\n   */\n  doneIcon?: React.ReactNode;\n  /** Pressing while downloading aborts the request and returns to idle. */\n  cancelable?: boolean;\n  /** Called after a press cancels the download. */\n  onCancel?: () => void;\n  /** Accessible name while idle. */\n  label?: string;\n  /** Accessible name and announcement while downloading. */\n  loadingLabel?: string;\n  /** Accessible name while downloading and `cancelable`. */\n  cancelLabel?: string;\n  /** Accessible name while done; describes what pressing does. */\n  doneLabel?: string;\n  /** Announced when the download finishes. */\n  doneAnnouncement?: string;\n  /** Diameter in px, also published as `--download-button-size`. */\n  size?: number;\n  /** Ring morph between states. Read when a state change starts it. */\n  transition?: Transition;\n  /**\n   * Whether the done state shows the check badge. The badge says the download\n   * finished; the done icon says what pressing does next. Without it the done\n   * icon is centred.\n   */\n  badge?: boolean;\n  /**\n   * Classes for the check badge. Its disc is `currentColor`, like the ring.\n   * The gap around it and the check are `--download-button-surface` (default\n   * `--background`); on a card set e.g. `[--download-button-surface:var(--card)]`\n   * on the button or any ancestor.\n   */\n  badgeClassName?: string;\n};\n\nexport function DownloadButton({\n  onDownload,\n  onError,\n  doneDuration,\n  onDoneClick,\n  icon = <ArrowDownToLine />,\n  doneIcon,\n  cancelable = false,\n  onCancel,\n  label = \"Download\",\n  loadingLabel = \"Downloading…\",\n  cancelLabel = \"Cancel download\",\n  doneLabel = \"Download again\",\n  doneAnnouncement = \"Download complete\",\n  size = VIEW_BOX,\n  transition = DEFAULT_TRANSITION,\n  badge = true,\n  badgeClassName,\n  className,\n  style,\n  type = \"button\",\n  disabled,\n  onClick,\n  ...props\n}: DownloadButtonProps) {\n  const [state, setState] = React.useState<DownloadButtonState>(\"idle\");\n  const [progress, setProgress] = React.useState<number | null>(null);\n  const reduceMotion = useReducedMotion();\n  const requestRef = React.useRef<AbortController | null>(null);\n  const ridingHead = doneIcon === undefined;\n  // The ring's geometry lives in motion values so the riding arrowhead can be\n  // computed from wherever the ring is, frame by frame.\n  const cx = useMotionValue(CENTER);\n  const cy = useMotionValue(CENTER);\n  const radius = useMotionValue(RING_RADIUS);\n  const strokeWidth = useMotionValue(2);\n  const length = useMotionValue(0);\n  const rotate = useMotionValue(RING_ROTATE);\n  const grow = useMotionValue(0);\n  const arrowhead = useTransform(\n    [cx, cy, radius, length, rotate, grow],\n    (values: number[]) => arrowheadPath(values),\n  );\n  // Collapsed, the head would still draw a round-capped dot.\n  const arrowheadOpacity = useTransform(grow, (value) =>\n    value > 0.02 ? 1 : 0,\n  );\n\n  React.useEffect(() => () => requestRef.current?.abort(), []);\n\n  React.useEffect(() => {\n    if (state !== \"done\" || doneDuration === undefined) return;\n    const timer = setTimeout(() => setState(\"idle\"), doneDuration);\n    return () => clearTimeout(timer);\n  }, [state, doneDuration]);\n\n  const indeterminate = state === \"loading\" && progress === null;\n\n  // Read through a ref so an inline `transition` object, new on every parent\n  // render, cannot restart a morph that is already running.\n  const transitionRef = React.useRef(transition);\n  React.useEffect(() => {\n    transitionRef.current = transition;\n  });\n  const previousStateRef = React.useRef(state);\n  const progressFrameRef = React.useRef<number | null>(null);\n  React.useEffect(\n    () => () => {\n      if (progressFrameRef.current !== null)\n        cancelAnimationFrame(progressFrameRef.current);\n    },\n    [],\n  );\n\n  // The ring's shape, driven imperatively so every change starts from where\n  // the ring is: the spinner never snaps back to 12 o'clock when it stops,\n  // and completion spins forward into place.\n  React.useEffect(() => {\n    const done = state === \"done\";\n    // Only the change into done earns the extra turn; a re-run while done\n    // (say, `badge` toggling) must not spin the landed icon again.\n    const enteringDone = done && previousStateRef.current !== \"done\";\n    previousStateRef.current = state;\n    // A custom done icon: collapse towards it, no spin, no change of length.\n    const collapse = done && !ridingHead;\n    const targets: [typeof cx, number][] = [\n      [cx, done ? ARC_CX + (badge ? 0 : UNBADGED_SHIFT[0]) : CENTER],\n      [cy, done ? ARC_CY + (badge ? 0 : UNBADGED_SHIFT[1]) : CENTER],\n      [radius, collapse ? COLLAPSE_RADIUS : done ? ARC_RADIUS : RING_RADIUS],\n      // Lands at the icon's stroke weight so the handover has no step.\n      [strokeWidth, done ? 2 * LUCIDE_SCALE : 2],\n    ];\n    const headTarget = done && ridingHead ? 1 : 0;\n    const rotateTarget = done ? ARC_ROTATE : RING_ROTATE;\n\n    if (reduceMotion) {\n      targets.forEach(([value, target]) => value.set(target));\n      grow.set(headTarget);\n      rotate.set(rotateTarget);\n      return;\n    }\n\n    const morph = collapse ? COLLAPSE : transitionRef.current;\n    const current = rotate.get();\n    const controls: AnimationPlaybackControls[] = targets.map(\n      ([value, target]) => animate(value, target, morph),\n    );\n    controls.push(\n      animate(\n        grow,\n        headTarget,\n        headTarget ? ARROWHEAD_GROW : { duration: 0.12, ease: EASE_OUT },\n      ),\n    );\n    if (indeterminate) {\n      controls.push(\n        animate(rotate, current + 360, {\n          duration: 0.9,\n          ease: \"linear\",\n          repeat: Infinity,\n        }),\n      );\n    } else if (!collapse) {\n      controls.push(\n        animate(\n          rotate,\n          forwardTo(current, rotateTarget, enteringDone ? 180 : 0),\n          transitionRef.current,\n        ),\n      );\n    }\n    return () => controls.forEach((control) => control.stop());\n  }, [\n    state,\n    indeterminate,\n    ridingHead,\n    badge,\n    reduceMotion,\n    cx,\n    cy,\n    radius,\n    strokeWidth,\n    length,\n    rotate,\n    grow,\n  ]);\n\n  // The arc's length is the only part progress moves, so reports animate it\n  // alone instead of restarting the whole shape.\n  React.useEffect(() => {\n    const done = state === \"done\";\n    if (done && !ridingHead) return;\n    const target = done\n      ? ARC_LENGTH\n      : state === \"loading\"\n        ? (progress ?? SPINNER_LENGTH)\n        : 0;\n    if (reduceMotion) {\n      length.set(target);\n      return;\n    }\n    const control = animate(length, target, transitionRef.current);\n    return () => control.stop();\n  }, [state, progress, ridingHead, reduceMotion, length]);\n\n  async function download() {\n    const controller = new AbortController();\n    let pendingProgress = 0;\n    requestRef.current = controller;\n    setProgress(null);\n    setState(\"loading\");\n    try {\n      await onDownload({\n        signal: controller.signal,\n        setProgress: (value) => {\n          // A non-finite report carries no information; keep the last one.\n          if (requestRef.current !== controller || !Number.isFinite(value))\n            return;\n          pendingProgress = Math.min(1, Math.max(0, value));\n          // Streams can report hundreds of times a second; render once a frame.\n          progressFrameRef.current ??= requestAnimationFrame(() => {\n            progressFrameRef.current = null;\n            if (requestRef.current === controller) setProgress(pendingProgress);\n          });\n        },\n      });\n      if (!controller.signal.aborted) setState(\"done\");\n    } catch (error) {\n      if (controller.signal.aborted) return;\n      setState(\"idle\");\n      onError?.(error);\n    } finally {\n      if (requestRef.current === controller) requestRef.current = null;\n    }\n  }\n\n  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {\n    onClick?.(event);\n    if (disabled || event.defaultPrevented) return;\n    if (state === \"loading\") {\n      if (!cancelable) return;\n      requestRef.current?.abort();\n      requestRef.current = null;\n      setState(\"idle\");\n      onCancel?.();\n    } else if (state === \"done\" && onDoneClick) {\n      onDoneClick(event);\n    } else if (!requestRef.current) {\n      void download();\n    }\n  }\n\n  const done = state === \"done\";\n  const loading = state === \"loading\";\n  const showStop = loading && cancelable;\n  const morph = reduceMotion ? { duration: 0 } : transition;\n  const fade: Transition = reduceMotion\n    ? { duration: 0 }\n    : { duration: 0.18, ease: EASE_OUT };\n  const collapse = done && !ridingHead;\n\n  return (\n    <button\n      {...props}\n      type={type}\n      disabled={disabled}\n      aria-label={\n        loading\n          ? cancelable\n            ? cancelLabel\n            : loadingLabel\n          : done\n            ? doneLabel\n            : label\n      }\n      aria-busy={loading}\n      // Not `disabled` while loading, so keyboard focus stays on the button.\n      aria-disabled={(loading && !cancelable) || undefined}\n      data-slot=\"download-button\"\n      data-state={state}\n      data-cancelable={cancelable || undefined}\n      data-progress={loading && progress !== null ? progress : undefined}\n      onClick={handleClick}\n      style={{\n        [\"--download-button-size\" as string]: `${size}px`,\n        width: \"var(--download-button-size)\",\n        height: \"var(--download-button-size)\",\n        ...style,\n      }}\n      className={cn(\n        \"relative inline-grid shrink-0 place-items-center rounded-full text-foreground outline-none\",\n        \"transition-transform duration-100 ease-out active:scale-[0.94]\",\n        // Tailwind's preflight gives buttons the default cursor, so state it.\n        // A cancelable download stays a pointer: pressing it does something.\n        // Otherwise `progress`, not `wait` (only this button is busy) or\n        // `not-allowed` (nothing is forbidden; it is simply working).\n        \"cursor-pointer aria-disabled:cursor-progress aria-disabled:active:scale-100\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n        \"motion-reduce:transition-none motion-reduce:active:scale-100\",\n        className,\n      )}\n    >\n      <svg\n        aria-hidden\n        viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`}\n        className=\"size-full overflow-visible\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      >\n        {/* Idle outline, dimmed into the progress track while loading. */}\n        <motion.circle\n          data-slot=\"download-button-track\"\n          cx={cx}\n          cy={cy}\n          r={radius}\n          strokeWidth={strokeWidth}\n          initial={false}\n          animate={{ opacity: done ? 0 : loading ? 0.2 : 1 }}\n          transition={collapse && !reduceMotion ? COLLAPSE_FADE : morph}\n        />\n        <motion.g\n          initial={false}\n          // A zero-length path with round caps is still a dot, so idle hides\n          // the ring. Done, it stays as the default icon, or fades out under\n          // a custom one.\n          animate={{ opacity: loading || (done && ridingHead) ? 1 : 0 }}\n          transition={\n            reduceMotion || loading || (done && ridingHead)\n              ? { duration: 0 }\n              : done\n                ? COLLAPSE_FADE\n                : fade\n          }\n        >\n          <motion.circle\n            data-slot=\"download-button-ring\"\n            cx={cx}\n            cy={cy}\n            r={radius}\n            strokeWidth={strokeWidth}\n            style={{ rotate, pathLength: length }}\n          />\n          <motion.path\n            data-slot=\"download-button-arrowhead\"\n            d={arrowhead}\n            strokeWidth={strokeWidth}\n            style={{ opacity: arrowheadOpacity }}\n          />\n        </motion.g>\n        {/* In the ring's SVG, not an HTML overlay: an overlay sized in\n            fractions of a pixel snaps sideways when its transform is removed\n            at the end of the pop. */}\n        {badge && (\n          <motion.g\n            data-slot=\"download-button-badge\"\n            className={badgeClassName}\n            stroke=\"none\"\n            initial={false}\n            animate={{ scale: done ? 1 : 0, opacity: done ? 1 : 0 }}\n            transition={\n              reduceMotion ? { duration: 0 } : done ? BADGE_POP : fade\n            }\n          >\n            <circle\n              cx={BADGE_CX}\n              cy={BADGE_CY}\n              r={BADGE_RADIUS + BADGE_GAP}\n              fill={SURFACE}\n            />\n            <circle\n              cx={BADGE_CX}\n              cy={BADGE_CY}\n              r={BADGE_RADIUS}\n              fill=\"currentColor\"\n            />\n            <path\n              d={CHECK_PATH}\n              transform={CHECK_TRANSFORM}\n              fill=\"none\"\n              stroke={SURFACE}\n              strokeWidth={3.5}\n            />\n          </motion.g>\n        )}\n      </svg>\n      <motion.span\n        aria-hidden\n        data-slot=\"download-button-icon\"\n        className={ICON_SLOT}\n        style={ICON_STYLE}\n        initial={false}\n        animate={\n          collapse\n            ? { y: 0, scale: 0.5, opacity: 0, filter: \"blur(2px)\" }\n            : {\n                y: done ? 6 : 0,\n                scale: done || showStop ? 0.6 : 1,\n                opacity: done || showStop ? 0 : 1,\n                filter: \"blur(0px)\",\n              }\n        }\n        transition={collapse && !reduceMotion ? SWAP_OUT : fade}\n      >\n        {icon}\n      </motion.span>\n      {cancelable && (\n        <motion.span\n          aria-hidden\n          data-slot=\"download-button-stop\"\n          className={cn(ICON_SLOT, \"[&_svg]:fill-current\")}\n          style={ICON_STYLE}\n          initial={false}\n          animate={{ scale: showStop ? 0.5 : 0.3, opacity: showStop ? 1 : 0 }}\n          transition={fade}\n        >\n          <Square strokeWidth={0} />\n        </motion.span>\n      )}\n      {!ridingHead && (\n        <motion.span\n          aria-hidden\n          data-slot=\"download-button-done-icon\"\n          className={ICON_SLOT}\n          // Kept on its own layer throughout: an icon at a fractional offset\n          // would otherwise snap sideways when its transform is dropped.\n          style={{\n            ...(badge ? DONE_ICON_STYLE : ICON_STYLE),\n            willChange: \"transform\",\n          }}\n          initial={false}\n          animate={\n            done\n              ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n              : { scale: 0.5, opacity: 0, filter: \"blur(2px)\" }\n          }\n          transition={done && !reduceMotion ? SWAP_IN : fade}\n        >\n          {doneIcon}\n        </motion.span>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {loading ? loadingLabel : done ? doneAnnouncement : \"\"}\n      </span>\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/download-button.tsx"
    }
  ],
  "meta": {
    "tags": [
      "download",
      "progress",
      "async-action",
      "loading",
      "success",
      "cancel",
      "abort-signal",
      "button"
    ],
    "effects": [
      "progress-ring",
      "svg-animation",
      "state-transition",
      "reduced-motion"
    ]
  },
  "categories": [
    "action"
  ],
  "type": "registry:ui"
}