{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline-scrubber",
  "type": "registry:component",
  "title": "Timeline Scrubber",
  "description": "Range slider for scrubbing through canvas state playback, with optional milestone ticks.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/timeline-scrubber/timeline-scrubber.tsx",
      "content": "\"use client\";\n\nimport {\n  type ChangeEvent,\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n  useId,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\n/**\n * One milestone tick rendered along the scrubber track.\n *\n * @public\n */\nexport type TimelineTick = {\n  /** Stable identifier — used as the React key + analytics hook. */\n  id: string;\n  /** Optional accessible label (e.g. `\"deploy\"`, `\"alert\"`). */\n  label?: ReactNode;\n  /** Optional tone for the tick. Defaults to `\"neutral\"`. */\n  tone?: TimelineScrubberTone;\n  /** Time value of the tick. */\n  value: number;\n};\n\n/**\n * Tone of the scrubber's filled track + handle.\n *\n * @public\n */\nexport type TimelineScrubberTone =\n  | \"danger\"\n  | \"neutral\"\n  | \"primary\"\n  | \"success\"\n  | \"warn\";\n\nconst TONE_FILL: Record<TimelineScrubberTone, string> = {\n  danger: \"bg-red-500\",\n  neutral: \"bg-foreground\",\n  primary: \"bg-blue-500\",\n  success: \"bg-emerald-500\",\n  warn: \"bg-amber-500\",\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type TimelineScrubberLabels = {\n  /** Aria-label for the slider. Defaults to `\"Timeline scrubber\"`. */\n  region?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Timeline scrubber\",\n} as const satisfies Required<TimelineScrubberLabels>;\n\n/**\n * Props for {@link TimelineScrubber}.\n *\n * @public\n */\nexport type TimelineScrubberProps = {\n  /** End of the time range. Must be `> start`. */\n  end: number;\n  /** Optional formatter for the cursor + endpoint labels. Receives the raw value. */\n  formatValue?: (value: number) => ReactNode;\n  /** Localizable strings. */\n  labels?: TimelineScrubberLabels;\n  /** Change handler — receives the new clamped value. */\n  onValueChange: (value: number) => void;\n  /** Start of the time range. */\n  start: number;\n  /** Step granularity for the underlying range input. Defaults to `1`. */\n  step?: number;\n  /** Optional milestone ticks rendered along the track. */\n  ticks?: TimelineTick[];\n  /** Tone of the filled track + handle. Defaults to `\"primary\"`. */\n  tone?: TimelineScrubberTone;\n  /** Current scrub value `start..end`. */\n  value: number;\n} & Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\">;\n\nconst clamp = (v: number, min: number, max: number): number => {\n  if (v < min) {\n    return min;\n  }\n  if (v > max) {\n    return max;\n  }\n  return v;\n};\n\ntype LabelsRow = {\n  clamped: number;\n  end: number;\n  formatValue?: (value: number) => ReactNode;\n  start: number;\n};\n\nconst Labels = (props: LabelsRow): React.ReactElement => {\n  const fmt = props.formatValue;\n  return (\n    <div className=\"flex items-baseline justify-between gap-2\">\n      <span data-timeline-scrubber-start>\n        {fmt ? fmt(props.start) : props.start}\n      </span>\n      <span\n        className=\"font-semibold text-foreground\"\n        data-timeline-scrubber-cursor\n      >\n        {fmt ? fmt(props.clamped) : props.clamped}\n      </span>\n      <span data-timeline-scrubber-end>{fmt ? fmt(props.end) : props.end}</span>\n    </div>\n  );\n};\n\ntype TrackInput = {\n  ariaLabel: string;\n  inputId: string;\n  max: number;\n  min: number;\n  onChange: (event: ChangeEvent<HTMLInputElement>) => void;\n  ratio: number;\n  scrubberId: string;\n  span: number;\n  start: number;\n  step: number;\n  ticks?: TimelineTick[];\n  tone: TimelineScrubberTone;\n  value: number;\n};\n\nconst Track = (props: TrackInput): React.ReactElement => (\n  <div className=\"relative h-6\">\n    <span\n      aria-hidden=\"true\"\n      className=\"absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted\"\n    />\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full\",\n        TONE_FILL[props.tone],\n      )}\n      data-timeline-scrubber-fill\n      style={{ width: `${props.ratio * 100}%` }}\n    />\n    {props.ticks?.map((tick) => (\n      <TickMark\n        key={tick.id}\n        scrubberId={props.scrubberId}\n        span={props.span}\n        start={props.start}\n        tick={tick}\n      />\n    ))}\n    <input\n      aria-label={props.ariaLabel}\n      className=\"absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      data-timeline-scrubber-input\n      id={props.inputId}\n      max={props.max}\n      min={props.min}\n      onChange={props.onChange}\n      step={props.step}\n      type=\"range\"\n      value={props.value}\n    />\n  </div>\n);\n\nconst TickMark = (props: {\n  scrubberId: string;\n  span: number;\n  start: number;\n  tick: TimelineTick;\n}): React.ReactElement => {\n  const { scrubberId, span, start, tick } = props;\n  const ratio = clamp((tick.value - start) / span, 0, 1);\n  const tone = tick.tone ?? \"neutral\";\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"absolute top-1/2 h-2.5 w-px -translate-y-1/2 rounded-full\",\n        TONE_FILL[tone],\n      )}\n      data-scrubber-tick={tick.id}\n      data-scrubber-tick-of={scrubberId}\n      data-scrubber-tick-tone={tone}\n      style={{ left: `${ratio * 100}%` }}\n      title={typeof tick.label === \"string\" ? tick.label : undefined}\n    />\n  );\n};\n\n/**\n * Range slider for scrubbing through canvas state playback. Renders a\n * thin track with optional milestone ticks plus the current value\n * cursor; the underlying `<input type=\"range\">` keeps keyboard +\n * pointer + screen-reader semantics for free.\n *\n * Pure presentation; the host owns the value + drives playback in its\n * own loop. Pair with {@link \"../playback-ghost/playback-ghost\".PlaybackGhost} to fade the canvas\n * back to historical state as the user scrubs.\n *\n * @example\n * ```tsx\n * <TimelineScrubber\n *   start={0} end={3600}\n *   value={cursor}\n *   onValueChange={setCursor}\n *   ticks={milestones}\n *   formatValue={(v) => formatDuration(v)}\n * />\n * ```\n *\n * @public\n */\nexport const TimelineScrubber = forwardRef<\n  HTMLDivElement,\n  TimelineScrubberProps\n>((props, ref) => {\n  const {\n    className,\n    end,\n    formatValue,\n    labels,\n    onValueChange,\n    start,\n    step = 1,\n    ticks,\n    tone = \"primary\",\n    value,\n    ...rest\n  } = props;\n  const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n  const inputId = useId();\n  const safeEnd = end <= start ? start + 1 : end;\n  const span = safeEnd - start;\n  const clamped = clamp(value, start, safeEnd);\n  const ratio = clamp((clamped - start) / span, 0, 1);\n  const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {\n    onValueChange(clamp(Number(event.target.value), start, safeEnd));\n  };\n  return (\n    <div\n      aria-label={resolvedLabels.region}\n      className={cn(\n        \"flex w-full flex-col gap-1 text-[11px] text-muted-foreground\",\n        className,\n      )}\n      data-timeline-scrubber\n      data-timeline-tone={tone}\n      ref={ref}\n      role=\"group\"\n      {...rest}\n    >\n      <Labels\n        clamped={clamped}\n        end={safeEnd}\n        formatValue={formatValue}\n        start={start}\n      />\n      <Track\n        ariaLabel={resolvedLabels.region}\n        inputId={inputId}\n        max={safeEnd}\n        min={start}\n        onChange={handleChange}\n        ratio={ratio}\n        scrubberId={inputId}\n        span={span}\n        start={start}\n        step={step}\n        ticks={ticks}\n        tone={tone}\n        value={clamped}\n      />\n    </div>\n  );\n});\nTimelineScrubber.displayName = \"TimelineScrubber\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
