Timeline Scrubber

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

Signaler un 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

Explorez les variantes, controles et verifications d'accessibilite dans le playground Storybook interactif.

Voir dans Storybook

4 stories disponibles :

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

Dependances

  • @vllnt/ui@^0.2.1