{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-wick",
  "title": "Progress Wick",
  "description": "A determinate progress bar that advances by capillary action (quick pull, slowing soak, brief dwell, next pull), with a faint wet-front runner previewing the track a few pixels ahead of the true fill, so bursty real-world progress (chunked uploads) reads as natural rather than janky.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/progress-wick/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// WickRun — a determinate progress bar that advances by capillary action\n// instead of gliding. True progress sets a `target`; the visible fill chases\n// it in discrete draws (quick pull, slowing soak, brief dwell, next pull),\n// each draw closing ~60% of the remaining gap with ease-out-expo over 300ms\n// then holding 150ms — an asymptotic approach that re-aims itself whenever\n// the target moves mid-draw. A second element at --foreground, low opacity,\n// rides 4-10px ahead of the true fill edge during each draw (its lead\n// collapsing to ~0 through the dwell) as a faint wet-front preview of where\n// the fill is about to reach. Widths/positions are written as CSS custom\n// properties (`--wick-fill`, `--wick-front`) from one rAF loop that sleeps\n// the instant the gap closes, and wakes again only when `value` moves.\n// Indeterminate mode drops the fill and sends the front pill alone down the\n// track in the same draw-dwell cadence, slowed down, looping. `aria-valuenow`\n// tracks the TRUE `value` directly every render — never the eased display —\n// so a screen reader is never behind the animation; milestone crossings\n// (25/50/75/100) are announced through a separate polite live region, and a\n// Geist Mono percentage sits beside the track so the reading never depends\n// on the bar alone. Reduced motion strips the wet front and the draws\n// collapse to a single 150ms linear width transition per update.\n// ---------------------------------------------------------------------------\n\nconst DRAW_MS = 300;\nconst DWELL_MS = 150;\nconst DRAW_FRACTION = 0.6; // each draw closes 60% of the remaining gap\nconst SETTLE_EPS = 0.05; // percent — below this the run is \"arrived\"\n\nconst SLOW_DRAW_MS = 480;\nconst SLOW_DWELL_MS = 260;\nconst SLOW_SETTLE_EPS = 2; // looser: indeterminate resets before crawling the last %\nconst TRAVEL_START = -16; // % — front pill starts just off the left edge\nconst TRAVEL_END = 116; // % — and exits just off the right edge\n\nconst MILESTONES = [25, 50, 75, 100];\n\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, n));\n}\n\nfunction easeOutExpo(t: number) {\n  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);\n}\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nexport interface WickRunProps {\n  /** true progress, 0-100 (controlled). Ignored while `indeterminate` is set. */\n  value?: number;\n  /** unknown-duration mode: the wet front alone travels the track in slow draws, no fill */\n  indeterminate?: boolean;\n  /** visible + accessible label, e.g. \"Uploading assets.zip\" (default \"Progress\") */\n  label?: string;\n  /** announce 25/50/75/100% crossings via a polite live region (default true) */\n  announceMilestones?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function WickRun({\n  value = 0,\n  indeterminate = false,\n  label = \"Progress\",\n  announceMilestones = true,\n  className = \"\",\n}: WickRunProps) {\n  const uid = useId();\n  const labelId = `${uid}-label`;\n  const reduced = useReducedMotion();\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const retargetRef = useRef<((v: number) => void) | null>(null);\n  const lastMilestoneRef = useRef(0);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const clampedValue = clamp(value, 0, 100);\n\n  // Milestone announcements read the TRUE value directly, decoupled from the\n  // rAF loop entirely, so assistive tech is never a beat behind the fill.\n  useEffect(() => {\n    if (indeterminate || !announceMilestones) return;\n    if (clampedValue < lastMilestoneRef.current) lastMilestoneRef.current = 0;\n    for (const m of MILESTONES) {\n      if (clampedValue >= m && lastMilestoneRef.current < m) {\n        lastMilestoneRef.current = m;\n        setAnnounce(m === 100 ? \"Complete\" : `${m}% complete`);\n      }\n    }\n  }, [clampedValue, indeterminate, announceMilestones]);\n\n  // Engine: mounts once per indeterminate/reduced-motion combination, then\n  // sleeps between updates. A separate effect below feeds it every `value`\n  // change through `retargetRef` without re-running this setup.\n  useEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n\n    if (reduced) {\n      // Reduced motion: no rAF, no wet front (never rendered, see JSX) — a\n      // plain width write lands on the CSS `transition: width 150ms linear`\n      // declared below, so every update glides once, linearly, and stops.\n      // Indeterminate has nothing to animate under reduced motion, so it\n      // paints one static partial bar rather than an uninformative 0-width\n      // track — `aria-valuetext` still carries the real \"in progress\" state.\n      if (indeterminate) {\n        track.style.setProperty(\"--wick-fill\", \"35%\");\n        return;\n      }\n      const paint = (v: number) => {\n        track.style.setProperty(\"--wick-fill\", `${clamp(v, 0, 100)}%`);\n      };\n      paint(clampedValue);\n      retargetRef.current = paint;\n      return () => {\n        retargetRef.current = null;\n      };\n    }\n\n    let raf = 0;\n    let trackW = track.clientWidth || 1;\n    let phase: \"draw\" | \"dwell\" | \"idle\" = \"idle\";\n    let phaseStart = 0;\n    let display = indeterminate ? TRAVEL_START : 0;\n    let target = indeterminate ? TRAVEL_END : clampedValue;\n    let drawFrom = display;\n    let drawTo = display;\n\n    const drawMs = indeterminate ? SLOW_DRAW_MS : DRAW_MS;\n    const dwellMs = indeterminate ? SLOW_DWELL_MS : DWELL_MS;\n    const eps = indeterminate ? SLOW_SETTLE_EPS : SETTLE_EPS;\n    const pxToPct = (px: number) => (px / trackW) * 100;\n\n    const beginDraw = (now: number) => {\n      const gap = target - display;\n      drawFrom = display;\n      drawTo = display + gap * DRAW_FRACTION;\n      phase = \"draw\";\n      phaseStart = now;\n    };\n\n    const paint = (fillPct: number, leadPct: number) => {\n      const fill = clamp(fillPct, 0, 100);\n      track.style.setProperty(\"--wick-fill\", `${indeterminate ? 0 : fill}%`);\n      const frontPos = indeterminate ? fillPct : fill + leadPct;\n      track.style.setProperty(\"--wick-front\", `${frontPos}%`);\n    };\n\n    const loop = (now: number) => {\n      if (phase === \"idle\" && Math.abs(target - display) > eps) beginDraw(now);\n\n      let leadPct = 0;\n      if (phase === \"draw\") {\n        const p = clamp((now - phaseStart) / drawMs, 0, 1);\n        display = drawFrom + (drawTo - drawFrom) * easeOutExpo(p);\n        if (!indeterminate) leadPct = pxToPct(10 - 6 * p); // quick pull -> slowing soak\n        if (p >= 1) {\n          display = drawTo;\n          phase = \"dwell\";\n          phaseStart = now;\n        }\n      } else if (phase === \"dwell\") {\n        const p = clamp((now - phaseStart) / dwellMs, 0, 1);\n        if (!indeterminate) leadPct = pxToPct(4 * (1 - p)); // front settles back onto the fill\n        if (p >= 1) {\n          const gap = target - display;\n          if (Math.abs(gap) <= eps) {\n            display = indeterminate ? TRAVEL_START : target;\n            phase = \"idle\";\n          } else {\n            phase = \"idle\"; // re-aim next tick against the latest target\n          }\n        }\n      }\n\n      paint(display, leadPct);\n\n      const settled = !indeterminate && phase === \"idle\" && Math.abs(target - display) <= eps;\n      raf = settled ? 0 : requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n\n    retargetRef.current = (v: number) => {\n      if (indeterminate) return;\n      target = clamp(v, 0, 100);\n      wake();\n    };\n\n    wake();\n\n    const ro = new ResizeObserver(() => {\n      trackW = track.clientWidth || 1;\n    });\n    ro.observe(track);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      retargetRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- clampedValue read once at mount, then fed via retargetRef\n  }, [indeterminate, reduced]);\n\n  useEffect(() => {\n    retargetRef.current?.(clampedValue);\n  }, [clampedValue]);\n\n  const valueText = indeterminate ? \"In progress\" : `${Math.round(clampedValue)}%`;\n\n  return (\n    <div className={className}>\n      <style>{`\n.ns-wick-fill{width:var(--wick-fill,0%)}\n.ns-wick-front{left:var(--wick-front,0%)}\n@media (prefers-reduced-motion: reduce){\n  .ns-wick-fill{transition:width 150ms linear}\n}\n`}</style>\n\n      <div className=\"mb-1.5 flex items-baseline justify-between gap-3\">\n        <span id={labelId} className=\"font-mono text-xs text-ns-muted\">\n          {label}\n        </span>\n        {!indeterminate ? (\n          <span className=\"font-mono text-xs tabular-nums text-foreground\" aria-hidden>\n            {Math.round(clampedValue)}%\n          </span>\n        ) : null}\n      </div>\n\n      <div\n        ref={trackRef}\n        role=\"progressbar\"\n        aria-labelledby={labelId}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={indeterminate ? undefined : Math.round(clampedValue)}\n        aria-valuetext={valueText}\n        className=\"relative h-1.5 overflow-hidden rounded-full border border-border\"\n      >\n        <div className=\"ns-wick-fill absolute inset-y-0 left-0 rounded-full bg-foreground\" />\n        {!reduced ? (\n          <div className=\"ns-wick-front absolute inset-y-0 w-3.5 -translate-x-1/2 rounded-full bg-foreground opacity-25\" />\n        ) : null}\n      </div>\n\n      {announceMilestones && !indeterminate ? (\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/progress-wick.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "progress",
      "loader",
      "upload",
      "physics",
      "micro-interaction",
      "accessibility"
    ],
    "instruction": "A determinate `role=progressbar` (0-100 `value`, controlled) whose visible fill does not glide to its target but chases it in discrete capillary draws. Internally a single rAF loop tracks a `target` (the true `value`, clamped) and a `display` (the eased, shown percent); whenever they differ by more than a small epsilon it starts a 'draw': `display` eases from its current position toward `display + (target - display) * 0.6` — 60% of the remaining gap — over 300ms with ease-out-expo, then holds ('dwells') for 150ms before re-checking the gap against the (possibly since-moved) `target` and either settling or starting the next draw. Because each draw only closes 60% of what's left, a fixed `target` produces a naturally decaying sequence of draws that converges asymptotically rather than one animation; a `target` that moves mid-draw or mid-dwell is simply picked up by the next draw's gap calculation, so bursty real progress (a chunked upload landing irregular chunks) drives a rhythm that already looks native rather than stuttering against a glide. A second element at `bg-foreground opacity-25`, a 14px pill centered on a CSS `left` position, rides ahead of the true fill edge during the draw phase — its lead in pixels running `10 -> 4` across the draw (quick pull, slowing soak) then continuing down to 0 across the dwell (the front settles back onto the fill) — a faint preview of where the bar is about to reach, never touching a raw hex value since both elements are `bg-foreground`. Fill width and front position are written every frame as CSS custom properties (`--wick-fill`, `--wick-front`) on the track element, read by two static one-line style rules (`width:var(--wick-fill,0%)`, `left:var(--wick-front,0%)`); the rAF loop stops entirely once the gap closes and only wakes again when `value` changes, via a ref-held retarget function set up once per mount so the value-watching effect never re-runs the engine setup. `indeterminate` (default false) drops the fill to zero width and instead sends the front pill alone traveling the same draw-dwell cadence, slowed (480ms draws, 260ms dwells) and continuous: it resets to just off the left edge and re-travels toward just off the right edge every time it arrives, looping for as long as the component is mounted, ignoring `value` entirely while active. Accessibility: `aria-valuenow` is set from the true `value` (rounded) on every render, completely decoupled from the animation loop, so a screen reader is never a beat behind what's on screen (indeterminate correctly omits `aria-valuenow` and instead sets `aria-valuetext=\"In progress\"`); a visually-hidden `role=status`/`aria-live=polite` span separately announces 25/50/75/100% milestone crossings ('Complete' at 100), computed straight off `value` with a ref tracking the last-announced threshold (reset if `value` drops back below it) so nothing double-fires; a Geist Mono percentage label sits beside the visible text label so the reading is never conveyed by bar length alone. `prefers-reduced-motion: reduce` (checked via `matchMedia` with a live change listener) removes the wet-front element from the DOM outright and switches the fill to a plain CSS `transition: width 150ms linear` driven directly by `value` with no rAF loop at all; a reduced-motion indeterminate render has nothing to animate, so it paints one static partial-width bar instead of an uninformative empty track, with the real 'in progress' state still carried by `aria-valuetext`. Props: `value` (0-100, default 0), `indeterminate` (default false), `label` (visible + accessible name, default \"Progress\"), `announceMilestones` (default true), `className`. DOM+CSS only, no canvas, no SVG, no dependencies.",
    "rank": 285
  },
  "type": "registry:ui"
}