Timeline Scrubber

Range slider for scrubbing through canvas state playback, with optional milestone ticks.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/timeline-scrubber.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client";

import {
  type ChangeEvent,
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
  useId,
} from "react";

import { cn } from "../../lib/utils";

/**
 * One milestone tick rendered along the scrubber track.
 *
 * @public
 */
export type TimelineTick = {
  /** Stable identifier — used as the React key + analytics hook. */
  id: string;
  /** Optional accessible label (e.g. `"deploy"`, `"alert"`). */
  label?: ReactNode;
  /** Optional tone for the tick. Defaults to `"neutral"`. */
  tone?: TimelineScrubberTone;
  /** Time value of the tick. */
  value: number;
};

/**
 * Tone of the scrubber's filled track + handle.
 *
 * @public
 */
export type TimelineScrubberTone =
  | "danger"
  | "neutral"
  | "primary"
  | "success"
  | "warn";

const TONE_FILL: Record<TimelineScrubberTone, string> = {
  danger: "bg-red-500",
  neutral: "bg-foreground",
  primary: "bg-blue-500",
  success: "bg-emerald-500",
  warn: "bg-amber-500",
};

/**
 * Localizable strings.
 *
 * @public
 */
export type TimelineScrubberLabels = {
  /** Aria-label for the slider. Defaults to `"Timeline scrubber"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  region: "Timeline scrubber",
} as const satisfies Required<TimelineScrubberLabels>;

/**
 * Props for {@link TimelineScrubber}.
 *
 * @public
 */
export type TimelineScrubberProps = {
  /** End of the time range. Must be `> start`. */
  end: number;
  /** Optional formatter for the cursor + endpoint labels. Receives the raw value. */
  formatValue?: (value: number) => ReactNode;
  /** Localizable strings. */
  labels?: TimelineScrubberLabels;
  /** Change handler — receives the new clamped value. */
  onValueChange: (value: number) => void;
  /** Start of the time range. */
  start: number;
  /** Step granularity for the underlying range input. Defaults to `1`. */
  step?: number;
  /** Optional milestone ticks rendered along the track. */
  ticks?: TimelineTick[];
  /** Tone of the filled track + handle. Defaults to `"primary"`. */
  tone?: TimelineScrubberTone;
  /** Current scrub value `start..end`. */
  value: number;
} & Omit<ComponentPropsWithoutRef<"div">, "onChange">;

const clamp = (v: number, min: number, max: number): number => {
  if (v < min) {
    return min;
  }
  if (v > max) {
    return max;
  }
  return v;
};

type LabelsRow = {
  clamped: number;
  end: number;
  formatValue?: (value: number) => ReactNode;
  start: number;
};

const Labels = (props: LabelsRow): React.ReactElement => {
  const fmt = props.formatValue;
  return (
    <div className="flex items-baseline justify-between gap-2">
      <span data-timeline-scrubber-start>
        {fmt ? fmt(props.start) : props.start}
      </span>
      <span
        className="font-semibold text-foreground"
        data-timeline-scrubber-cursor
      >
        {fmt ? fmt(props.clamped) : props.clamped}
      </span>
      <span data-timeline-scrubber-end>{fmt ? fmt(props.end) : props.end}</span>
    </div>
  );
};

type TrackInput = {
  ariaLabel: string;
  inputId: string;
  max: number;
  min: number;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  ratio: number;
  scrubberId: string;
  span: number;
  start: number;
  step: number;
  ticks?: TimelineTick[];
  tone: TimelineScrubberTone;
  value: number;
};

const Track = (props: TrackInput): React.ReactElement => (
  <div className="relative h-6">
    <span
      aria-hidden="true"
      className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted"
    />
    <span
      aria-hidden="true"
      className={cn(
        "absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full",
        TONE_FILL[props.tone],
      )}
      data-timeline-scrubber-fill
      style={{ width: `${props.ratio * 100}%` }}
    />
    {props.ticks?.map((tick) => (
      <TickMark
        key={tick.id}
        scrubberId={props.scrubberId}
        span={props.span}
        start={props.start}
        tick={tick}
      />
    ))}
    <input
      aria-label={props.ariaLabel}
      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"
      data-timeline-scrubber-input
      id={props.inputId}
      max={props.max}
      min={props.min}
      onChange={props.onChange}
      step={props.step}
      type="range"
      value={props.value}
    />
  </div>
);

const TickMark = (props: {
  scrubberId: string;
  span: number;
  start: number;
  tick: TimelineTick;
}): React.ReactElement => {
  const { scrubberId, span, start, tick } = props;
  const ratio = clamp((tick.value - start) / span, 0, 1);
  const tone = tick.tone ?? "neutral";
  return (
    <span
      aria-hidden="true"
      className={cn(
        "absolute top-1/2 h-2.5 w-px -translate-y-1/2 rounded-full",
        TONE_FILL[tone],
      )}
      data-scrubber-tick={tick.id}
      data-scrubber-tick-of={scrubberId}
      data-scrubber-tick-tone={tone}
      style={{ left: `${ratio * 100}%` }}
      title={typeof tick.label === "string" ? tick.label : undefined}
    />
  );
};

/**
 * Range slider for scrubbing through canvas state playback. Renders a
 * thin track with optional milestone ticks plus the current value
 * cursor; the underlying `<input type="range">` keeps keyboard +
 * pointer + screen-reader semantics for free.
 *
 * Pure presentation; the host owns the value + drives playback in its
 * own loop. Pair with {@link "../playback-ghost/playback-ghost".PlaybackGhost} to fade the canvas
 * back to historical state as the user scrubs.
 *
 * @example
 * ```tsx
 * <TimelineScrubber
 *   start={0} end={3600}
 *   value={cursor}
 *   onValueChange={setCursor}
 *   ticks={milestones}
 *   formatValue={(v) => formatDuration(v)}
 * />
 * ```
 *
 * @public
 */
export const TimelineScrubber = forwardRef<
  HTMLDivElement,
  TimelineScrubberProps
>((props, ref) => {
  const {
    className,
    end,
    formatValue,
    labels,
    onValueChange,
    start,
    step = 1,
    ticks,
    tone = "primary",
    value,
    ...rest
  } = props;
  const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
  const inputId = useId();
  const safeEnd = end <= start ? start + 1 : end;
  const span = safeEnd - start;
  const clamped = clamp(value, start, safeEnd);
  const ratio = clamp((clamped - start) / span, 0, 1);
  const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
    onValueChange(clamp(Number(event.target.value), start, safeEnd));
  };
  return (
    <div
      aria-label={resolvedLabels.region}
      className={cn(
        "flex w-full flex-col gap-1 text-[11px] text-muted-foreground",
        className,
      )}
      data-timeline-scrubber
      data-timeline-tone={tone}
      ref={ref}
      role="group"
      {...rest}
    >
      <Labels
        clamped={clamped}
        end={safeEnd}
        formatValue={formatValue}
        start={start}
      />
      <Track
        ariaLabel={resolvedLabels.region}
        inputId={inputId}
        max={safeEnd}
        min={start}
        onChange={handleChange}
        ratio={ratio}
        scrubberId={inputId}
        span={span}
        start={start}
        step={step}
        ticks={ticks}
        tone={tone}
        value={clamped}
      />
    </div>
  );
});
TimelineScrubber.displayName = "TimelineScrubber";
typescript

Dependencies

  • @vllnt/ui@^0.2.1