{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-scrubber",
  "type": "registry:ui",
  "title": "Time scrubber",
  "description": "A moment in a run that already happened, found by dragging a ruler of its time under a fixed needle: ticks every step, labelled times, and what happened along the run marked in its status color. The ruler follows the pointer one to one and settles on the nearest step or an event, Page Up and Page Down go from event to event, and in fun mode it can be thrown.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/time-scrubber.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/time-scrubber 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Time scrubber: a point in a run's time, set by dragging a ruler under a fixed needle, such as a moment in a deploy to\n * replay its log up to there (components.md, \"Time scrubber\"). The ruler moves; the needle stays in the middle.\n *\n * - Anatomy: the Slider's label row (the label at the small size in --litt-fg, the reading at its end in tabular figures, with the\n *   label of the event under the needle before it, in --litt-fg-subtle), then a 56px track: the Pad's well, because a\n *   scrubber is a one-axis pad, a ruler you reach into (R3, material.md 6: the cut). The --litt-cut floor inside the\n *   --litt-line-control line, with its top wall's shade (--litt-cut-shade), at the surface radius; the line and the shade are\n *   drawn on a layer above the ruler, so the ruler passes under the edge. On the floor a ruler: a minor tick every step\n *   (8px, --litt-notch, the step mark), a major tick every `majorEvery` steps (14px, --litt-fg-disabled) with its time under it\n *   at the caption size in --litt-fg-subtle, tabular, and the events as 2px marks, 20px tall, in their status color\n *   (neutral is --litt-fg-muted). The ticks are lighter than any mark in light mode and dimmer in dark, so a mark stands\n *   out by lightness, height and width, not by hue alone (in light mode the status colors are as dark as\n *   --litt-fg-subtle, so --litt-fg-subtle ticks would hide them). A warning's mark carries a 6px diamond on its top and a\n *   danger's a 6px dot, so status is never color alone (WCAG 1.4.1), and in forced colors, where every mark is\n *   CanvasText, the shapes still tell them apart. Steps sit 12px apart.\n * - The needle is the Slider's cap, fixed in the middle and riding over the ruler: 8px wide, at the control radius less\n *   2px, with a grip line down its face (--litt-cap, --litt-cap-lift: lit on its top edge, casting straight down, R2). It stands\n *   from 4px under the top wall to above the times, so it never cuts one, and the grip line is the exact time. An event\n *   it lands on slides under it; the label row names that event (R5). Never the accent (R6). The hand holds the ruler,\n *   not the cap, so the cap never sinks.\n * - Drag: the ruler follows the pointer one to one, as the Slider's thumb does. Let go and it settles on the nearest\n *   step, or on an event within half a step of the needle, on the responsive tier and from rest, so it never passes\n *   either end. A press without a drag goes to the time under the pointer.\n * - Keys and assistive tech: the track is the slider (role slider, named by the label), so the reading, the event and\n *   the bounds are read out. ← and → move a step, with Shift ten; Page Up and Page Down go to the next and previous\n *   event (ten steps when there are none); Home and End go to the ends. The value is the time, aria-valuetext says it\n *   in words with the event's label.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): the ruler can be thrown. Let go and it keeps the\n *   speed it had, coasts as far as that speed carries it, and lands on the event nearest there (within three steps),\n *   else on the nearest step, on fun mode's throw spring. Let go without a throw, it settles as it does without fun\n *   mode. Past either end it gives like a rubber band. It rests\n *   exactly where it does without fun mode. Reduced motion turns it off.\n * - Motion: the ruler moves by transform only. Under reduced motion it jumps to where it settles.\n * - Touch: the track owns sideways drags (data-no-drag, touch-action pan-y), so the page still scrolls up and down.\n * - Flat (proposals/flat-pill.md): the track is a grey field with no outline and no shade; the ruler, the marks and the\n *   cap are as they are.\n * - Disabled: everything lies flat: the well --litt-surface-3 with the plain --litt-line hairline (a step below the cap, so the\n *   flat cap still shows), the cap --litt-surface-2 with a --litt-line edge and a --litt-line grip. No pointer and no tab stop.\n * - Forced colors drops box-shadow and the floors, so the track gets a real border and the cap a border and the\n *   system highlight.\n * - Long runs: the minor ticks are one repeating background, and only the major ticks near the needle are drawn.\n */\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { animate, motion, useMotionValue, useMotionValueEvent, type AnimationPlaybackControls } from \"motion/react\";\nimport { useFun, useTransition } from \"../../lib/litt/motion\";\n\nexport type TimeScrubberTone = \"neutral\" | \"success\" | \"warning\" | \"danger\";\n\nexport type TimeScrubberMark = {\n  /** When it happened, in the scrubber's units (the same as min, max and value). */\n  at: number;\n  /** What happened, short: \"Build completed\". Shown beside the reading when the needle is on it, and read out. */\n  label: string;\n  /** The status color of its mark. Neutral is --litt-fg-muted. Color is never alone: a warning carries a diamond, a danger a dot, and the label says the rest. */\n  tone?: TimeScrubberTone;\n};\n\nexport type TimeScrubberProps = {\n  /** The label above the ruler. Required: it names the slider. */\n  label: ReactNode;\n  /** The time under the needle, when you control it. Pair it with onValueChange. */\n  value?: number;\n  /** The time it starts at when you do not control it. Without one, it starts at min. */\n  defaultValue?: number;\n  /** Called with the new time as it moves, on the step grid or on an event. */\n  onValueChange?: (value: number) => void;\n  /** Called once it has settled after a drag, a press or a key. Use it to load what happened at that time. */\n  onValueCommit?: (value: number) => void;\n  /** The start of the run. */\n  min?: number;\n  /** The end of the run. Required. */\n  max: number;\n  /** The gap between ticks, in the same units. Keys move one step, and a drag settles on the grid. */\n  step?: number;\n  /** A labelled major tick every this many steps. */\n  majorEvery?: number;\n  /** What happened along the run. The needle settles on one it is let go near, and Page Up and Page Down go between them. */\n  marks?: TimeScrubberMark[];\n  /** Turns a time into words, such as 12.4 into \"+12.4s\". It feeds the reading, the tick labels and aria-valuetext. */\n  formatValue?: (value: number) => string;\n  /** Mutes it, skips it in the tab order and ignores the pointer. */\n  disabled?: boolean;\n  /** Extra classes for the wrapper, such as a width. */\n  className?: string;\n};\n\n/** Px between two minor ticks. The ruler's tick background repeats every 12px too: change them together. */\nconst STEP_PX = 12;\n/** Px of travel before a press is a drag. */\nconst SLOP = 4;\n/** The release speed is read over this many ms of movement. */\nconst WINDOW_MS = 100;\n/** Fun mode: a throw lands on an event within this many steps of where it would coast to. */\nconst PULL_STEPS = 3;\n\nconst TONE: Record<TimeScrubberTone, string> = {\n  neutral: \"bg-litt-fg-muted\",\n  success: \"bg-litt-success\",\n  warning: \"bg-litt-warning\",\n  danger: \"bg-litt-danger\",\n};\n\n/** The head on a mark's top, so a warning and a danger differ from the rest by shape as well as color. */\nconst HEAD: Partial<Record<TimeScrubberTone, string>> = {\n  warning: \"rotate-45\",\n  danger: \"rounded-litt-pill\",\n};\n\n/** Digits after the point, so 0.1 steps land on 0.3 and not 0.30000000000000004. */\nconst places = (n: number) => (String(n).split(\".\")[1] ?? \"\").length;\n\n/** A ruler of time under a fixed needle: drag it, press it, or use the keys. Events sit on it as marks in their status color. */\nexport function TimeScrubber({\n  label,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max,\n  step = 1,\n  majorEvery = 5,\n  marks = [],\n  formatValue = String,\n  disabled = false,\n  className = \"\",\n}: TimeScrubberProps) {\n  const id = useId();\n  const labelId = `${id}-label`;\n  const hintId = `${id}-hint`;\n  const responsive = useTransition(\"responsive\");\n  const fun = useFun();\n  const settle = fun ? fun.throw : responsive;\n\n  const perUnit = STEP_PX / step;\n  const digits = Math.max(places(step), places(min));\n  const clamp = (t: number) => Math.min(max, Math.max(min, t));\n  const onGrid = (t: number) => clamp(Number((Math.round((t - min) / step) * step + min).toFixed(digits)));\n  const sorted = useMemo(() => [...marks].sort((a, b) => a.at - b.at), [marks]);\n\n  /** Where a time comes to rest: the event nearest it within `pull` px, else the nearest step. */\n  const restFor = (t: number, pull: number) => {\n    let best: TimeScrubberMark | null = null;\n    for (const m of sorted) if (Math.abs(m.at - t) * perUnit <= pull && (!best || Math.abs(m.at - t) < Math.abs(best.at - t))) best = m;\n    return best ? clamp(best.at) : onGrid(t);\n  };\n\n  const [inner, setInner] = useState(() => clamp(defaultValue ?? min));\n  const value = clamp(valueProp ?? inner);\n  const mark = sorted.find((m) => m.at === value);\n  const reading = formatValue(value);\n\n  /* The ruler's offset: the needle sits at the track's middle, so time t is under it when x is -(t - min) per unit. */\n  const toX = (t: number) => -(t - min) * perUnit;\n  const toT = (x: number) => min - x / perUnit;\n  const x = useMotionValue(toX(value));\n  const run = useRef<AnimationPlaybackControls | null>(null);\n  const said = useRef(value); // the last value told to onValueChange, so a drag reports each step once\n\n  const report = (t: number) => {\n    if (t === said.current) return;\n    said.current = t;\n    if (valueProp === undefined) setInner(t);\n    onValueChange?.(t);\n  };\n\n  /** Settle on `t`, from wherever the ruler is, carrying `velocity` (px/s), then commit it. */\n  const settleTo = (t: number, velocity = 0) => {\n    run.current?.stop();\n    report(t);\n    const controls = animate(x, toX(t), { ...settle, velocity });\n    run.current = controls;\n    void controls.then(() => {\n      if (run.current !== controls) return;\n      run.current = null;\n      onValueCommit?.(t);\n    });\n  };\n\n  /* A value from outside (a controlled change we did not make) moves the ruler there. */\n  useEffect(() => {\n    if (value === said.current) return;\n    said.current = value;\n    run.current?.stop();\n    run.current = animate(x, toX(value), settle);\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- follows the value only\n  }, [value]);\n\n  useEffect(() => () => run.current?.stop(), []);\n\n  /* The track's width, for the major ticks near the needle and for a press. */\n  const track = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = track.current;\n    if (!el) return;\n    const read = () => setWidth(el.clientWidth);\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  /* Only the major ticks within a track's width either side of the needle are drawn. The window moves in whole majors,\n     so the ticks re-render only when one comes into reach, not on every frame. */\n  const majorSpan = step * majorEvery;\n  const reach = Math.ceil(width / (STEP_PX * majorEvery)) + 1;\n  const windowAt = (t: number) => Math.round((t - min) / majorSpan);\n  const [centre, setCentre] = useState(() => windowAt(value));\n  useMotionValueEvent(x, \"change\", (v) => {\n    const c = windowAt(toT(v));\n    if (c !== centre) setCentre(c);\n  });\n  const majors: number[] = [];\n  const last = Math.floor((max - min) / majorSpan + 1e-9);\n  for (let k = Math.max(0, centre - reach); k <= Math.min(last, centre + reach); k++) majors.push(Number((min + k * majorSpan).toFixed(digits)));\n\n  /* ------------------------------------------------------------------ the pointer */\n\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const el = e.currentTarget;\n    const pointer = e.pointerId;\n    const x0 = e.clientX;\n    run.current?.stop(); // caught mid-settle: it is in the hand again, where it is\n    run.current = null;\n    const from = x.get();\n    const lo = toX(max);\n    const hi = toX(min);\n    let dragging = false;\n    let trail: { t: number; x: number }[] = [];\n\n    const move = (ev: globalThis.PointerEvent) => {\n      if (ev.pointerId !== pointer) return;\n      const dx = ev.clientX - x0;\n      if (!dragging) {\n        if (Math.abs(dx) < SLOP) return;\n        dragging = true;\n        try {\n          el.setPointerCapture(pointer);\n        } catch {\n          /* the pointer is already gone */\n        }\n      }\n      const want = from + dx;\n      const kept = Math.min(hi, Math.max(lo, want));\n      x.set(fun && want !== kept ? kept + fun.rubber(want - kept) : kept);\n      report(onGrid(toT(kept)));\n      trail.push({ t: ev.timeStamp, x: kept });\n      trail = trail.filter((p) => ev.timeStamp - p.t <= WINDOW_MS);\n    };\n    const up = (ev: globalThis.PointerEvent) => {\n      if (ev.pointerId !== pointer) return;\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      if (!dragging) {\n        /* A press: the time under the pointer, settled as a release there would be. */\n        if (ev.type === \"pointercancel\") return settleTo(onGrid(toT(from)));\n        const box = el.getBoundingClientRect();\n        return settleTo(restFor(toT(from) + (ev.clientX - (box.left + box.width / 2)) / perUnit, STEP_PX / 2));\n      }\n      const first = trail[0];\n      const end = trail[trail.length - 1];\n      const fresh = end && ev.timeStamp - end.t <= WINDOW_MS;\n      const velocity = fresh && first && end.t > first.t ? ((end.x - first.x) / (end.t - first.t)) * 1000 : 0;\n      const at = toT(Math.min(hi, Math.max(lo, x.get())));\n      /* Fun mode: where the speed would carry it, and an event near there. Without it, or let go without a throw:\n         where it was let go, and an event within half a step. */\n      const carry = fun ? fun.coast(velocity) : 0;\n      const thrown = Math.abs(carry) > STEP_PX;\n      const target = restFor(toT(toX(at) + carry), thrown ? STEP_PX * PULL_STEPS : STEP_PX / 2);\n      settleTo(target, fun ? velocity : 0); // without fun mode it settles from rest, so it never passes an end\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  };\n\n  /* ------------------------------------------------------------------ the keys */\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const steps = e.shiftKey ? 10 : 1;\n    const next = sorted.find((m) => m.at > value);\n    const prev = [...sorted].reverse().find((m) => m.at < value);\n    let to: number | null = null;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") to = onGrid(value + steps * step);\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") to = onGrid(value - steps * step);\n    else if (e.key === \"PageUp\") to = sorted.length ? (next?.at ?? max) : onGrid(value + 10 * step);\n    else if (e.key === \"PageDown\") to = sorted.length ? (prev?.at ?? min) : onGrid(value - 10 * step);\n    else if (e.key === \"Home\") to = min;\n    else if (e.key === \"End\") to = max;\n    if (to === null) return;\n    e.preventDefault();\n    if (to !== value) settleTo(clamp(to));\n  };\n\n  const span = (max - min) * perUnit;\n  const valueText = mark ? `${reading}, ${mark.label}` : reading;\n\n  return (\n    <div className={\"grid gap-2 \" + className}>\n      <div className=\"flex items-baseline justify-between gap-4\">\n        <span id={labelId} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n          {label}\n        </span>\n        <span aria-hidden=\"true\" className=\"flex min-w-0 items-baseline gap-2 text-litt-small\">\n          {mark && <span className=\"truncate text-litt-fg-subtle\">{mark.label}</span>}\n          <span className={\"shrink-0 tabular-nums \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg-muted\")}>{reading}</span>\n        </span>\n      </div>\n      <div\n        ref={track}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-labelledby={labelId}\n        aria-describedby={hintId}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={value}\n        aria-valuetext={valueText}\n        aria-disabled={disabled || undefined}\n        data-no-drag=\"\"\n        onPointerDown={onPointerDown}\n        onKeyDown={onKeyDown}\n        className={\n          \"relative h-litt-control-lg touch-pan-y overflow-hidden rounded-litt-surface select-none forced-colors:border \" +\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n          (disabled ? \"cursor-not-allowed bg-litt-surface-3\" : \"cursor-grab bg-litt-cut active:cursor-grabbing\")\n        }\n      >\n        {/* The ruler: its start (min) sits under the needle when x is 0, and it slides left as time goes on. */}\n        <motion.div aria-hidden=\"true\" className=\"absolute inset-y-0 left-1/2\" style={{ x }}>\n          <div\n            className=\"absolute top-2 left-0 h-2 bg-[repeating-linear-gradient(to_right,var(--litt-notch)_0_1px,transparent_1px_12px)]\"\n            style={{ width: span + 1 }}\n          />\n          {majors.map((t) => (\n            <div key={t} className=\"absolute top-2 flex -translate-x-1/2 flex-col items-center gap-1.5\" style={{ left: (t - min) * perUnit }}>\n              <span className=\"h-3.5 w-px bg-litt-fg-disabled\" />\n              <span className=\"text-litt-caption font-normal whitespace-nowrap text-litt-fg-subtle tabular-nums\">{formatValue(t)}</span>\n            </div>\n          ))}\n          {sorted.map((m) => {\n            const tone = m.tone ?? \"neutral\";\n            return (\n              <span\n                key={`${m.at}-${m.label}`}\n                className={\"absolute top-2 h-5 w-0.5 -translate-x-1/2 rounded-litt-pill forced-colors:bg-[CanvasText] \" + TONE[tone]}\n                style={{ left: (m.at - min) * perUnit }}\n              >\n                {HEAD[tone] && (\n                  <span className={\"absolute -top-0.5 left-1/2 size-1.5 -translate-x-1/2 forced-colors:bg-[CanvasText] \" + TONE[tone] + \" \" + HEAD[tone]} />\n                )}\n              </span>\n            );\n          })}\n        </motion.div>\n        {/* The well's edge and its top wall's shade, over the ruler, so it slides under them. */}\n        <span\n          aria-hidden=\"true\"\n          className={\n            \"pointer-events-none absolute inset-0 rounded-litt-surface \" +\n            (disabled ? \"shadow-[inset_0_0_0_1px_var(--litt-line)]\" : \"shadow-[inset_0_0_0_1px_var(--litt-line-control),var(--litt-cut-shade)]\") +\n            \" in-[[data-material=flat]]:shadow-none\"\n          }\n        />\n        {/* The cap: its grip line marks the exact time. */}\n        <span\n          aria-hidden=\"true\"\n          className={\n            \"pointer-events-none absolute top-1 left-1/2 h-6 w-2 -translate-x-1/2 rounded-[calc(var(--litt-radius-control)-2px)] \" +\n            \"forced-colors:border forced-colors:bg-[Highlight] \" +\n            (disabled ? \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)] in-[[data-material=flat]]:shadow-none\" : \"bg-litt-cap shadow-(--litt-cap-lift)\")\n          }\n        >\n          <span className={\"absolute inset-y-2 left-1/2 w-px -translate-x-1/2 \" + (disabled ? \"bg-litt-line\" : \"bg-litt-cap-grip\")} />\n        </span>\n      </div>\n      <span id={hintId} className=\"sr-only\">\n        {sorted.length ? \"Arrow keys move a step. Page Up and Page Down go to the next and previous event.\" : \"Arrow keys move a step.\"}\n      </span>\n    </div>\n  );\n}\n",
      "target": "components/litt/time-scrubber.tsx"
    }
  ],
  "docs": "Import TimeScrubber from components/litt/time-scrubber. Give it a label, max (and min), the run's events as marks ({ at, label, tone }) and a formatValue for the units (\"+12s\", \"09:30\"). Hold the value in state and show what it replays beside it, such as a LogView with the lines up to the needle. A setting that stays is a Slider.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
