{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-bar",
  "type": "registry:ui",
  "title": "Progress Bar",
  "description": "Rolling percentage, buffer, stepped segments and a tick that draws on completion.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/progress-bar.tsx",
      "content": "\"use client\";\nimport { Progress } from \"@base-ui/react/progress\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n// The indeterminate sweep is a keyframe (it loops and is never interrupted).\n// Under reduced motion it stops traveling and the whole track breathes instead,\n// specific enough to outrank a global animation kill switch.\nconst CSS = `\n@keyframes stealth-progress-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }\n@keyframes stealth-progress-breathe { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.6; } }\n.stealth-progress [data-part=\"sweep\"] { animation: stealth-progress-sweep 1.5s cubic-bezier(0.76, 0, 0.24, 1) infinite; }\n@media (prefers-reduced-motion: reduce) {\n  .stealth-progress [data-part=\"sweep\"] { width: 100% !important; transform: none !important; animation: stealth-progress-breathe 2.4s ease-in-out infinite !important; }\n}\n`;\n\nconst HEIGHT = { sm: \"h-[3px]\", md: \"h-1\", lg: \"h-1.5\" } as const;\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\nexport type ProgressBarProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Current value. null means indeterminate: the work has started but its size isn't known. */\n  value: number | null;\n  min?: number;\n  max?: number;\n  /** Visible label above the track, also its accessible name. Without it, pass aria-label. */\n  label?: React.ReactNode;\n  /** A quieter line under the track: \"2.4 MB of 5.8 MB · about 12s left\". */\n  description?: React.ReactNode;\n  /** Show the rolling value at the end of the label row. Defaults to true when there is a label. */\n  showValue?: boolean;\n  /** How far ahead something else has got: downloaded vs played, read vs processed. Same scale as value. */\n  buffer?: number;\n  /** Split the track into equal steps, for \"step 2 of 4\". Each step fills in turn. */\n  segments?: number;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** The work stopped and needs attention. The fill turns the danger color. */\n  error?: boolean;\n  /** The work is on hold. The fill steps back to a quieter color. */\n  paused?: boolean;\n  /** Intl options for the displayed value. Without them it shows a whole percentage. */\n  format?: Format;\n  locale?: Intl.LocalesArgument;\n  getAriaValueText?: (formattedValue: string, value: number | null) => string;\n};\n\nexport function ProgressBar({\n  value,\n  min = 0,\n  max = 100,\n  label,\n  description,\n  showValue,\n  buffer,\n  segments,\n  size = \"md\",\n  error = false,\n  paused = false,\n  format,\n  locale,\n  getAriaValueText,\n  className,\n  ...rest\n}: ProgressBarProps) {\n  const reduce = useReducedMotion();\n  const indeterminate = value === null || !Number.isFinite(value);\n  const range = max - min || 1;\n  const pct = indeterminate ? 0 : clamp01(((value as number) - min) / range) * 100;\n  const bufferPct = buffer == null ? null : clamp01((buffer - min) / range) * 100;\n  const complete = !indeterminate && pct >= 100 && !error;\n  const withValue = showValue ?? label != null;\n  const state = error ? \"error\" : paused ? \"paused\" : complete ? \"complete\" : indeterminate ? \"indeterminate\" : \"progressing\";\n\n  // Remember where the fill came from, so segments can fill one after another\n  // in the direction of travel. Adjusting state during render is React's\n  // sanctioned way to derive from a changing prop.\n  const [shown, setShown] = useState(pct);\n  const [from, setFrom] = useState(pct);\n  if (pct !== shown) {\n    setFrom(shown);\n    setShown(pct);\n  }\n\n  const fill = error ? \"bg-danger\" : paused ? \"bg-fg-3\" : \"bg-fg\";\n  const suffix = error ? \", failed\" : paused ? \", paused\" : \"\";\n\n  return (\n    <Progress.Root\n      value={indeterminate ? null : value}\n      min={min}\n      max={max}\n      format={format}\n      locale={locale}\n      getAriaValueText={(formatted, v) => (getAriaValueText ? getAriaValueText(formatted, v) : `${formatted ?? \"Loading\"}${suffix}`)}\n      data-state={state}\n      data-size={size}\n      className={cn(\"stealth-progress flex w-full min-w-0 flex-col gap-2\", className)}\n      {...rest}\n    >\n      <style href=\"stealth-progress-bar\" precedence=\"default\">\n        {CSS}\n      </style>\n\n      {(label != null || withValue) && (\n        <div className=\"flex h-[18px] min-w-0 items-center justify-between gap-3\">\n          {label != null ? <Progress.Label className=\"min-w-0 truncate text-[13px] leading-[18px] text-fg\">{label}</Progress.Label> : <span />}\n          {withValue && (\n            <Progress.Value\n              className={cn(\n                \"tabular grid h-[18px] shrink-0 items-center justify-items-end text-[12px] leading-[18px] transition-colors duration-200\",\n                error ? \"text-danger\" : paused ? \"text-fg-3\" : \"text-fg-2\",\n              )}\n            >\n              {() => (\n                // The number and the tick share one cell, so completing never shifts the row.\n                <>\n                  <span aria-hidden className=\"invisible col-start-1 row-start-1\">100%</span>\n                  <AnimatePresence initial={false}>\n                    {complete ? (\n                      <motion.span\n                        key=\"done\"\n                        className=\"col-start-1 row-start-1 flex h-[18px] items-center text-success\"\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n                        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                        transition={reduce ? { duration: 0.15 } : spring.pop}\n                      >\n                        <Tick reduce={!!reduce} />\n                      </motion.span>\n                    ) : indeterminate ? (\n                      <span key=\"none\" className=\"col-start-1 row-start-1\" />\n                    ) : (\n                      <motion.span\n                        key=\"value\"\n                        className=\"col-start-1 row-start-1\"\n                        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                      >\n                        <NumberFlow\n                          value={format ? (value as number) : pct / 100}\n                          format={format ?? { style: \"percent\", maximumFractionDigits: 0 }}\n                          locales={locale}\n                          animated={!reduce}\n                          willChange\n                        />\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                </>\n              )}\n            </Progress.Value>\n          )}\n        </div>\n      )}\n\n      {segments && segments > 1 && !indeterminate ? (\n        <div className={cn(\"flex w-full gap-[3px]\", HEIGHT[size])}>\n          {Array.from({ length: segments }, (_, i) => {\n            const f = clamp01((pct / 100) * segments - i);\n            const start = (from / 100) * segments;\n            const rising = pct >= from;\n            // Steps fill in order, 90ms apart, and empty in reverse.\n            const steps = rising ? Math.max(0, i - Math.floor(start)) : Math.max(0, Math.ceil(start) - 1 - i);\n            return (\n              <div key={i} className=\"relative h-full min-w-0 flex-1 overflow-hidden rounded-full bg-line-2\">\n                <div\n                  className={cn(\"absolute inset-0 rounded-full transition-[transform,background-color] duration-[360ms] ease-out-quart motion-reduce:duration-0\", fill)}\n                  style={{ transform: `translateX(${(f - 1) * 100}%)`, transitionDelay: reduce ? \"0ms\" : `${steps * 90}ms` }}\n                />\n              </div>\n            );\n          })}\n        </div>\n      ) : (\n        <Progress.Track className={cn(\"relative w-full overflow-hidden rounded-full bg-line-2\", HEIGHT[size])}>\n          {bufferPct != null && !indeterminate && (\n            <div\n              className=\"absolute inset-0 rounded-full bg-fg-4 transition-transform duration-500 ease-out-quart\"\n              style={{ transform: `translateX(${bufferPct - 100}%)` }}\n            />\n          )}\n          {indeterminate ? (\n            <Progress.Indicator data-part=\"sweep\" className={cn(\"absolute inset-y-0 left-0 w-2/5 rounded-full\", fill)} />\n          ) : (\n            // A full-width fill slid left keeps its rounded end at every value; scaling would squash it.\n            <Progress.Indicator\n              className={cn(\"absolute inset-y-0 left-0 rounded-full transition-[transform,background-color] duration-[480ms] ease-out-quart\", fill)}\n              style={{ width: \"100%\", transform: `translateX(${pct - 100}%)` }}\n            />\n          )}\n        </Progress.Track>\n      )}\n\n      {description != null && (\n        <div className={cn(\"min-w-0 text-[12px] leading-[18px] transition-colors duration-200\", error ? \"text-danger\" : \"text-fg-3\")}>{description}</div>\n      )}\n    </Progress.Root>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/progress-bar"
}
