{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stick-progress-bar",
  "title": "Stick progress bar",
  "description": "A progress bar drawn as a row of vertical sticks that grow in one after another, as one value against a total or as a split into coloured segments, with a hover tooltip.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/stick-progress-bar/stick-progress-bar.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotionConfig,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface StickProgressSegment {\n  id?: string;\n  label: string;\n  value: number;\n  /** Any CSS colour; a token such as `var(--go)` keeps it on the theme. */\n  color: string;\n  /** Stroke while this segment is hovered. Defaults to `color`. */\n  hoverColor?: string;\n  /**\n   * Optional colour ramp instead of one flat `color`: stops at positions along\n   * the WHOLE gauge (0–1, ascending), and each stick is mixed between the two\n   * stops either side of where it sits, so every stick has its own colour,\n   * fixed by position, not by how full the gauge is. Two stops at (almost) the\n   * same position make a hard break. Plain data, not a function, so a server\n   * component can pass it. Stops can be any CSS colour, tokens included; the\n   * mix is done by the browser (`color-mix`), so it follows theme switches.\n   */\n  ramp?: { at: number; color: string }[];\n}\n\nexport interface StickProgressBarProps {\n  /** Optional multi-segment configuration. Takes precedence over checked/total. */\n  segments?: StickProgressSegment[];\n  /** Number of items completed/checked (when segments not provided). */\n  checked?: number;\n  /** Total number of items (when segments not provided). */\n  total?: number;\n  /** Accessible label for screen readers. */\n  ariaLabel?: string;\n  /** Additional container styling. */\n  className?: string;\n  /** Active stick stroke colour. Defaults to var(--lead). */\n  activeColor?: string;\n  /**\n   * Optional left-to-right gradient stops for the active sticks\n   * (single-metric mode only; ignored when `segments` are given). Two or more\n   * CSS colours; each active stick takes the colour at its own position along\n   * them instead of `activeColor`. Meant for a brand mark (a model maker's\n   * colours on an attribution chart), never for state.\n   */\n  activeGradient?: string[];\n  /** Inactive stick stroke colour. Defaults to var(--rule-soft). */\n  inactiveColor?: string;\n  /** Stick stroke colour when hovering the inactive segment. Defaults to var(--faint). */\n  hoverInactiveColor?: string;\n  /** Target pitch (distance between stick centres) in px. Defaults to 8.5. */\n  pitch?: number;\n  /** Width (stroke width) of each vertical stick in px. Defaults to 3. */\n  stickWidth?: number;\n  /** Height of each stick in px. Defaults to 16. */\n  stickHeight?: number;\n  /** Whether to show the floating tooltip on hover. Defaults to true. */\n  showTooltip?: boolean;\n  /** Label for the active/checked segment in the tooltip. Defaults to \"Checked\". */\n  checkedLabel?: string;\n  /** Label for the inactive/remaining segment in the tooltip. Defaults to \"Still to check\". */\n  remainingLabel?: string;\n  /** Formatter for values in the tooltip. Defaults to .toLocaleString(). */\n  valueFormatter?: (value: number) => string;\n  /** Optional custom accessible value text for screen readers. */\n  ariaValueText?: string;\n  /**\n   * Skips the scroll-reveal fade-in and renders at full opacity from the first\n   * frame. For a gauge that is never below the fold when it mounts (a live\n   * progress row, say), waiting on an `IntersectionObserver` is pure risk: if\n   * its first callback lands while a parent entrance animation has the row at\n   * zero height, it may never fire again and the track stays invisible until\n   * hovered. Defaults to false so scroll-triggered charts keep their reveal.\n   */\n  alwaysVisible?: boolean;\n}\n\ntype HoverState = {\n  x: number;\n  segmentIndex: number;\n  segmentLabel: string;\n  segmentValue: number;\n  segmentPct: number;\n  segmentColor: string;\n};\n\nconst DEFAULT_HOVER_INACTIVE = \"var(--faint)\";\n\n/** `from` blended toward `to` by `fraction` (0–1), resolved by the browser. */\nfunction mixColour(from: string, to: string, fraction: number): string {\n  const t = Math.min(1, Math.max(0, fraction));\n  if (t <= 0 || from === to) return from;\n  if (t >= 1) return to;\n  const toPct = Math.round(t * 1000) / 10;\n  return `color-mix(in oklab, ${from}, ${to} ${toPct}%)`;\n}\n\ntype RampStop = { at: number; color: string };\n\n/** The colour at position `t` (0–1) along ascending ramp stops. */\nfunction rampStroke(stops: readonly RampStop[], t: number): string {\n  if (t <= stops[0].at) return stops[0].color;\n  for (let i = 1; i < stops.length; i++) {\n    const from = stops[i - 1];\n    const to = stops[i];\n    if (t <= to.at) {\n      return mixColour(from.color, to.color, (t - from.at) / Math.max(0.0001, to.at - from.at));\n    }\n  }\n  return stops[stops.length - 1].color;\n}\n\n/** The gradient colour at position `t` (0–1) across evenly spaced `stops`. */\nfunction sampleGradient(stops: readonly string[], t: number): string {\n  const clamped = Math.min(1, Math.max(0, t)) * (stops.length - 1);\n  const lower = Math.floor(clamped);\n  const upper = Math.min(stops.length - 1, lower + 1);\n  return mixColour(stops[lower], stops[upper], clamped - lower);\n}\n\nexport function StickProgressBar({\n  segments,\n  checked = 0,\n  total = 0,\n  ariaLabel = \"Progress\",\n  ariaValueText,\n  className,\n  activeColor = \"var(--lead)\",\n  activeGradient,\n  inactiveColor = \"var(--rule-soft)\",\n  hoverInactiveColor = DEFAULT_HOVER_INACTIVE,\n  pitch = 8.5,\n  stickWidth = 3,\n  stickHeight = 16,\n  showTooltip = true,\n  checkedLabel = \"Checked\",\n  remainingLabel = \"Still to check\",\n  valueFormatter = (v) => v.toLocaleString(),\n  alwaysVisible = false,\n}: StickProgressBarProps) {\n  const compId = useId().replace(/:/g, \"\");\n  const containerRef = useRef<HTMLDivElement>(null);\n  const reduceMotion = useReducedMotionConfig() ?? false;\n  const [width, setWidth] = useState<number>(0);\n  const [hasEnteredView, setHasEnteredView] = useState(alwaysVisible);\n  const [hoverState, setHoverState] = useState<HoverState | null>(null);\n\n  // Animated progress (0 to target active ticks count)\n  const progressMotion = useMotionValue(0);\n  const [displayProgress, setDisplayProgress] = useState(0);\n\n  useEffect(() => {\n    const node = containerRef.current;\n    if (!node) return;\n\n    const resizeObserver = new ResizeObserver(([entry]) => {\n      if (entry && entry.contentRect.width > 0) {\n        setWidth(entry.contentRect.width);\n      }\n    });\n    resizeObserver.observe(node);\n\n    // Already visible from the first frame: nothing to reveal, and nothing\n    // that can get stuck waiting on a callback that may never fire again.\n    if (alwaysVisible) {\n      return () => resizeObserver.disconnect();\n    }\n\n    const intersectionObserver = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setHasEnteredView(true);\n          intersectionObserver.disconnect();\n        }\n      },\n      { threshold: 0.1 },\n    );\n    intersectionObserver.observe(node);\n\n    return () => {\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n    };\n  }, [alwaysVisible]);\n\n  const hasSegments = Boolean(segments && segments.length > 0);\n\n  // Layout: evenly distribute sticks across the width.\n  const effectiveWidth = width > 0 ? width : 800;\n  const availableWidth = Math.max(1, effectiveWidth - stickWidth);\n  const intervals = Math.max(12, Math.round(availableWidth / pitch));\n  const totalTicks = intervals + 1;\n  const step = availableWidth / intervals;\n\n  // Single-metric mode\n  const clampedTotal = Math.max(0, total);\n  const clampedChecked = Math.max(0, Math.min(clampedTotal, checked));\n  const remaining = Math.max(0, clampedTotal - clampedChecked);\n  const singlePct = clampedTotal > 0 ? (clampedChecked / clampedTotal) * 100 : 0;\n\n  // Multi-segment mode\n  const totalSegmentsValue = useMemo(() => {\n    if (!hasSegments || !segments) return 0;\n    return segments.reduce((sum, s) => sum + Math.max(0, s.value), 0);\n  }, [hasSegments, segments]);\n\n  // Largest-remainder split of the sticks between segments; every positive\n  // segment keeps at least one stick when there are enough to go round.\n  const tickToSegmentIndex = useMemo(() => {\n    if (!hasSegments || !segments || totalSegmentsValue <= 0) return [];\n    const rawCounts = segments.map((s) => (Math.max(0, s.value) / totalSegmentsValue) * totalTicks);\n    const floorCounts = rawCounts.map((rc, idx) => ({\n      idx,\n      count: Math.floor(rc),\n      remainder: rc - Math.floor(rc),\n      positive: (segments[idx]?.value ?? 0) > 0,\n    }));\n    const positiveCount = floorCounts.filter((f) => f.positive).length;\n    if (totalTicks >= positiveCount) {\n      floorCounts.forEach((f) => {\n        if (f.positive && f.count === 0) f.count = 1;\n      });\n    }\n    const sumAssigned = floorCounts.reduce((acc, f) => acc + f.count, 0);\n    if (sumAssigned > totalTicks) {\n      const sorted = [...floorCounts].sort((a, b) => b.count - a.count);\n      let excess = sumAssigned - totalTicks;\n      for (const item of sorted) {\n        if (excess <= 0) break;\n        if (item.count > 1) {\n          const drop = Math.min(excess, item.count - 1);\n          item.count -= drop;\n          excess -= drop;\n        }\n      }\n    } else if (sumAssigned < totalTicks) {\n      const sorted = [...floorCounts].sort((a, b) => b.remainder - a.remainder);\n      let diff = totalTicks - sumAssigned;\n      for (const item of sorted) {\n        if (diff <= 0) break;\n        item.count += 1;\n        diff -= 1;\n      }\n    }\n\n    const mapping: number[] = [];\n    floorCounts.forEach((f) => {\n      for (let k = 0; k < f.count; k++) mapping.push(f.idx);\n    });\n    while (mapping.length < totalTicks) {\n      mapping.push(Math.max(0, segments.length - 1));\n    }\n    return mapping;\n  }, [hasSegments, segments, totalSegmentsValue, totalTicks]);\n\n  const targetTicksCount = useMemo(() => {\n    if (hasSegments) return totalTicks;\n    if (clampedTotal === 0 || clampedChecked === 0) return 0;\n    if (clampedChecked >= clampedTotal) return totalTicks;\n    return Math.max(1, Math.min(totalTicks, Math.round((clampedChecked / clampedTotal) * totalTicks)));\n  }, [hasSegments, clampedChecked, clampedTotal, totalTicks]);\n\n  // Sticks grow in one after another; reduced motion jumps to the target.\n  useEffect(() => {\n    if (!hasEnteredView) return;\n\n    const currentVal = progressMotion.get();\n    const delta = Math.abs(targetTicksCount - currentVal);\n    const duration = reduceMotion\n      ? 0\n      : Math.min(1.3, Math.max(0.6, (delta / Math.max(1, totalTicks)) * 2.0 + 0.5));\n\n    const controls = animate(progressMotion, targetTicksCount, {\n      duration,\n      ease: [0.16, 1, 0.3, 1],\n      onUpdate: (latest) => setDisplayProgress(latest),\n    });\n\n    return () => controls.stop();\n  }, [targetTicksCount, totalTicks, hasEnteredView, progressMotion, reduceMotion]);\n\n  const ticks = useMemo(() => {\n    const list = [];\n    const y1 = 3;\n    const y2 = 3 + stickHeight;\n    for (let i = 0; i < totalTicks; i++) {\n      list.push({\n        index: i,\n        x: stickWidth / 2 + i * step,\n        y1,\n        y2,\n        isTargetActive: i < targetTicksCount,\n      });\n    }\n    return list;\n  }, [totalTicks, stickWidth, step, stickHeight, targetTicksCount]);\n\n  // Gradient for the active sticks (single-metric mode only). Each active\n  // stick takes the solid mixed colour at its own position along the stops,\n  // so the row reads as a discrete colour flow. Deliberately per stick rather\n  // than an SVG linearGradient reference: a `url(#…)` paint server that fails\n  // to resolve paints as transparent, which reads as an empty gauge.\n  const gradientStops =\n    !hasSegments && activeGradient && activeGradient.length >= 2 ? activeGradient : null;\n  const gradientCss = gradientStops\n    ? `linear-gradient(90deg, ${gradientStops.join(\", \")})`\n    : null;\n\n  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const node = containerRef.current;\n    if (!node) return;\n    const rect = node.getBoundingClientRect();\n    const relativeX = e.clientX - rect.left;\n\n    if (hasSegments && segments && segments.length > 0) {\n      const tickIndex = Math.max(\n        0,\n        Math.min(totalTicks - 1, Math.round((relativeX - stickWidth / 2) / step)),\n      );\n      const segIndex = tickToSegmentIndex[tickIndex] ?? 0;\n      const seg = segments[segIndex];\n      if (seg) {\n        setHoverState({\n          x: relativeX,\n          segmentIndex: segIndex,\n          segmentLabel: seg.label,\n          segmentValue: seg.value,\n          segmentPct: totalSegmentsValue > 0 ? (seg.value / totalSegmentsValue) * 100 : 0,\n          segmentColor: seg.color,\n        });\n      }\n      return;\n    }\n\n    // Two-part mode (checked / remaining)\n    const splitX = stickWidth / 2 + (targetTicksCount - 0.5) * step;\n    const isChecked = relativeX <= splitX && targetTicksCount > 0;\n    setHoverState({\n      x: relativeX,\n      segmentIndex: isChecked ? 0 : 1,\n      segmentLabel: isChecked ? checkedLabel : remainingLabel,\n      segmentValue: isChecked ? clampedChecked : remaining,\n      segmentPct: isChecked ? singlePct : 100 - singlePct,\n      segmentColor: isChecked\n        ? (gradientCss ?? activeColor)\n        : hoverInactiveColor !== DEFAULT_HOVER_INACTIVE\n          ? hoverInactiveColor\n          : inactiveColor,\n    });\n  };\n\n  const isHovered = hoverState !== null;\n  const hoveredSegmentIndex = hoverState?.segmentIndex ?? null;\n\n  const tooltipX = useMemo(() => {\n    if (!hoverState) return 0;\n    const minPadding = Math.min(85, effectiveWidth / 2);\n    return Math.max(minPadding, Math.min(effectiveWidth - minPadding, hoverState.x));\n  }, [hoverState, effectiveWidth]);\n\n  const svgHeight = stickHeight + 6;\n  const stickTransition = reduceMotion\n    ? undefined\n    : \"transition-[stroke,stroke-width,opacity] duration-150\";\n\n  const ariaValueNow = Math.round(hasSegments ? 100 : singlePct);\n  const defaultAriaValueText = hasSegments\n    ? segments?.map((s) => `${s.label}: ${valueFormatter(s.value)}`).join(\", \")\n    : `${valueFormatter(clampedChecked)} of ${valueFormatter(clampedTotal)} (${singlePct.toFixed(1)}%)`;\n\n  return (\n    <div\n      ref={containerRef}\n      onPointerMove={handlePointerMove}\n      onPointerLeave={() => setHoverState(null)}\n      className={cn(\"relative w-full select-none\", className)}\n      role=\"progressbar\"\n      aria-valuenow={ariaValueNow}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-label={ariaLabel}\n      aria-valuetext={ariaValueText ?? defaultAriaValueText}\n    >\n      <svg\n        className=\"block w-full overflow-visible\"\n        height={svgHeight}\n        style={{ height: svgHeight }}\n        aria-hidden=\"true\"\n      >\n        {hasSegments && segments ? (\n          ticks.map((tick) => {\n            const segIdx = tickToSegmentIndex[tick.index] ?? 0;\n            const seg = segments[segIdx];\n            if (!seg) return null;\n\n            const isThisSegHovered = hoveredSegmentIndex === segIdx;\n            const baseStroke = seg.ramp?.length\n              ? rampStroke(seg.ramp, tick.index / Math.max(1, totalTicks - 1))\n              : seg.color;\n            const stroke = isThisSegHovered && seg.hoverColor ? seg.hoverColor : baseStroke;\n            const strokeW = isThisSegHovered ? stickWidth + 0.5 : stickWidth;\n            const opacity = isHovered ? (isThisSegHovered ? 1 : 0.3) : 1;\n\n            const fill = Math.min(1, Math.max(0, displayProgress - tick.index));\n            if (fill <= 0) return null;\n            const activeY2 = tick.y1 + Math.max(0.1, stickHeight * fill);\n\n            return (\n              <line\n                key={`seg-tick-${compId}-${tick.index}`}\n                x1={tick.x}\n                y1={tick.y1}\n                x2={tick.x}\n                y2={activeY2}\n                style={{ stroke }}\n                strokeWidth={strokeW}\n                strokeLinecap=\"round\"\n                opacity={opacity}\n                className={stickTransition}\n              />\n            );\n          })\n        ) : (\n          <>\n            {/* Layer 1: the track */}\n            {ticks.map((tick) => {\n              let baseStroke = tick.isTargetActive ? \"var(--rule-soft)\" : inactiveColor;\n              let opacity = hasEnteredView ? 1 : 0;\n              let strokeW = stickWidth;\n\n              if (isHovered) {\n                if (hoveredSegmentIndex === 1 && !tick.isTargetActive) {\n                  baseStroke = hoverInactiveColor;\n                  strokeW = stickWidth + 0.5;\n                  opacity = 1;\n                } else if (hoveredSegmentIndex === 0 && !tick.isTargetActive) {\n                  opacity = 0.25;\n                }\n              }\n\n              return (\n                <line\n                  key={`base-tick-${compId}-${tick.index}`}\n                  x1={tick.x}\n                  y1={tick.y1}\n                  x2={tick.x}\n                  y2={tick.y2}\n                  style={{ stroke: baseStroke }}\n                  strokeWidth={strokeW}\n                  strokeLinecap=\"round\"\n                  opacity={opacity}\n                  className={stickTransition}\n                />\n              );\n            })}\n\n            {/* Layer 2: the active sticks */}\n            {ticks.map((tick) => {\n              const fill = Math.min(1, Math.max(0, displayProgress - tick.index));\n              if (fill <= 0) return null;\n\n              const activeStroke = gradientStops\n                ? sampleGradient(\n                    gradientStops,\n                    targetTicksCount > 1 ? tick.index / (targetTicksCount - 1) : 0,\n                  )\n                : activeColor;\n              let strokeW = stickWidth;\n              let activeOpacity = Math.min(1, fill * 1.5);\n\n              if (isHovered) {\n                if (hoveredSegmentIndex === 0 && tick.isTargetActive) {\n                  strokeW = stickWidth + 0.5;\n                  activeOpacity = 1;\n                } else if (hoveredSegmentIndex === 1 && tick.isTargetActive) {\n                  activeOpacity = 0.25;\n                }\n              }\n\n              const activeY2 = tick.y1 + Math.max(0.1, stickHeight * fill);\n\n              return (\n                <line\n                  key={`active-tick-${compId}-${tick.index}`}\n                  x1={tick.x}\n                  y1={tick.y1}\n                  x2={tick.x}\n                  y2={activeY2}\n                  style={{ stroke: activeStroke }}\n                  strokeWidth={strokeW}\n                  strokeLinecap=\"round\"\n                  opacity={activeOpacity}\n                  className={stickTransition}\n                />\n              );\n            })}\n          </>\n        )}\n      </svg>\n\n      <AnimatePresence>\n        {showTooltip && hoverState && (\n          <motion.div\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 3 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 3 }}\n            transition={{ duration: reduceMotion ? 0 : 0.15, ease: \"easeOut\" }}\n            style={{ left: tooltipX }}\n            className=\"pointer-events-none absolute -top-10 z-50 -translate-x-1/2 whitespace-nowrap rounded-inset border border-on-solid/10 bg-ink px-2.5 py-1 font-body text-[11px] font-medium text-on-solid shadow-lg\"\n          >\n            <div className=\"flex items-center gap-1.5\">\n              <span\n                className=\"size-1.5 shrink-0 rounded-full\"\n                style={{ background: hoverState.segmentColor }}\n              />\n              <span className=\"font-semibold text-on-solid\">{hoverState.segmentLabel}</span>\n              <span className=\"font-mono tabular-nums text-on-solid/70\">\n                {valueFormatter(hoverState.segmentValue)} ({hoverState.segmentPct.toFixed(1)}%)\n              </span>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Reach for it to show a share of a whole on a card: progress against a limit, a split of spending, a count toward a milestone (with a position-fixed colour ramp). Not for values over time or comparisons of many series; use a chart for those.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/ui/horizontal-stick-gauge.tsx"
    },
    "credits": []
  },
  "type": "registry:component"
}
