← ui

timeline-view

lib

the window of time a timeline shows: zoom at a point, pan to the ends, ticks

npx shadcn add @ag/timeline-view
Already installed? Preview an update
npx shadcn add @ag/timeline-view --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • New: the window of time a timeline shows and the arithmetic that moves it (zoom at a point, pan stopping at the ends, ticks whose labels never touch, snapping in px), its own item so trace draws its axis without the timeline. timeline brings it as before.
Updated 2026-10-13 · release notes

shelf motion

part of timeline

uses clip, timebase

used by trace

the demo above, verbatim

timeline-view.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  instantAt,
  minSpanOf,
  panBy,
  ticksOf,
  type View,
  wholeView,
  xOf,
  zoomAt,
} from "@/registry/base-nova/lib/timeline-view"
import { Button } from "@/registry/base-nova/ui/button"

// #region ruler
/** Two minutes of a medium told in frames, 30 a second. */
const DURATION = 120_000
const FPS = 30
/** The narrowest view: a dozen frames. */
const NARROWEST = minSpanOf(FPS)
/** How far a wheel's px zooms: exp(-delta × this). */
const ZOOM_PER_PX = 0.01

function Ruler() {
  const [view, setView] = React.useState<View>(() => wholeView(DURATION))
  const [width, setWidth] = React.useState(0)
  const box = React.useRef<HTMLDivElement>(null)
  React.useLayoutEffect(() => {
    const el = box.current
    if (!el) return
    const watch = new ResizeObserver(([entry]) =>
      setWidth(entry?.contentRect.width ?? 0),
    )
    watch.observe(el)
    return () => watch.disconnect()
  }, [])
  // A wheel over the ruler is the ruler's: a sideways swipe pans, an upright one zooms
  // at the hand. React's wheel listener is passive, so it is added by hand to stop
  // the page scrolling under it.
  React.useEffect(() => {
    const el = box.current
    if (!el) return
    const onWheel = (event: WheelEvent) => {
      event.preventDefault()
      const rect = el.getBoundingClientRect()
      setView((v) =>
        Math.abs(event.deltaX) > Math.abs(event.deltaY)
          ? panBy(v, event.deltaX * ((v.to - v.from) / rect.width), DURATION)
          : zoomAt(
              v,
              instantAt(v, rect.width, event.clientX - rect.left),
              Math.exp(-event.deltaY * ZOOM_PER_PX),
              DURATION,
              NARROWEST,
            ),
      )
    }
    el.addEventListener("wheel", onWheel, { passive: false })
    return () => el.removeEventListener("wheel", onWheel)
  }, [])
  const ticks = width > 0 ? ticksOf(view, width, { fps: FPS }) : []
  const middle = (v: View) => (v.from + v.to) / 2
  return (
    <div className="space-y-3">
      <div
        ref={box}
        className="relative h-12 select-none overflow-hidden rounded-lg bg-foreground/4 font-mono text-[10px] text-foreground/60 tabular-nums"
      >
        {ticks.map((t) => (
          <div
            key={t.at}
            className="absolute inset-y-0"
            style={{ left: xOf(view, width, t.at) }}
          >
            <span
              className={
                t.major
                  ? "block h-3 w-px bg-foreground/40"
                  : "block h-1.5 w-px bg-foreground/20"
              }
            />
            {t.label && (
              <span className="absolute top-4 left-1 whitespace-nowrap">
                {t.label}
              </span>
            )}
          </div>
        ))}
      </div>
      <div className="flex items-center gap-2">
        <Button
          size="sm"
          variant="outline"
          onClick={() =>
            setView((v) => zoomAt(v, middle(v), 2, DURATION, NARROWEST))
          }
        >
          zoom in
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() =>
            setView((v) => zoomAt(v, middle(v), 0.5, DURATION, NARROWEST))
          }
        >
          zoom out
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => setView(wholeView(DURATION))}
        >
          whole
        </Button>
        <span className="ml-auto font-mono text-foreground/50 text-xs tabular-nums">
          {(view.from / 1000).toFixed(2)} s to {(view.to / 1000).toFixed(2)} s
        </span>
      </div>
    </div>
  )
}
// #endregion

export default function TimelineViewDemo() {
  return (
    <Sample
      name="ruler"
      glance="wide"
      title="a view that zooms and pans"
      description="a wheel over the ruler zooms at the hand, a sideways swipe pans; it stops at the ends and at a dozen frames, and its labels never touch"
    >
      <Ruler />
    </Sample>
  )
}

the source, every file an install writes

timeline-view.ts
// THE VIEW of a timeline (`TimeAxis`): the window of time the width shows, and the
// arithmetic that moves it. Framework-free: an instant is whole ms, a view is a span
// `{from, to}` inside `[0, duration]`, and every function here is pure.
//
//   zoom      at a point: the instant under the hand stays under the hand, from the
//             whole medium down to a few frames (`minSpan`), never past either.
//   pan       by ms; it STOPS at the medium's ends. Nothing rubber-bands: on a
//             trackpad nothing is under the finger, and a band only freezes the axis
//             while the hand keeps pushing (the lightbox's lesson).
//   ticks     the ruler's divisions at any zoom: a step from a ladder of round
//             durations (frames when the medium knows its rate), the major step the
//             first whose labels FIT between ticks, so two labels never touch; the
//             minor step the next finer one that still has room to be seen.
//   snap      an instant pulled onto the nearest of a few candidates within a radius
//             stated in px (what the hand feels), never in ms.

import type { Span } from "@/registry/base-nova/lib/clip"
import {
  clockTime,
  frameAt,
  frameTime,
  timecode,
} from "@/registry/base-nova/lib/timebase"

/** The window of time a timeline shows, ms. */
export type View = Span

function assert(ok: unknown, why: string): asserts ok {
  if (!ok) throw new Error(`timeline-view: ${why}`)
}

/** The narrowest view a medium allows: `frames` frames when it knows its rate, else
 *  `ms`. Zooming in stops there: past it the axis would only magnify one frame. */
export function minSpanOf(
  fps: number | null,
  { frames = 12, ms = 400 }: { frames?: number; ms?: number } = {},
): number {
  return fps ? (frames * 1000) / fps : ms
}

/** A view held inside `[0, duration]`, at least `minSpan` wide (never wider than the
 *  medium): moved, never squashed, where it overhangs an end. */
export function clampView(view: View, duration: number, minSpan: number): View {
  assert(duration > 0, `a medium of ${duration} ms`)
  const span = Math.min(duration, Math.max(minSpan, view.to - view.from))
  const from = Math.min(Math.max(0, view.from), duration - span)
  return { from, to: from + span }
}

/** The whole medium. */
export const wholeView = (duration: number): View => ({
  from: 0,
  to: duration,
})

/** Zoomed by `factor` (over 1 in, under 1 out) around `at`, which stays where it was
 *  across the width; held inside the medium and its narrowest view. */
export function zoomAt(
  view: View,
  at: number,
  factor: number,
  duration: number,
  minSpan: number,
): View {
  assert(factor > 0 && Number.isFinite(factor), `a zoom by ${factor}`)
  const span = view.to - view.from
  const next = Math.min(duration, Math.max(minSpan, span / factor))
  const share = span > 0 ? (at - view.from) / span : 0.5
  return clampView(
    { from: at - share * next, to: at - share * next + next },
    duration,
    minSpan,
  )
}

/** Moved by `ms` (later when positive), stopping at the medium's ends. */
export function panBy(view: View, ms: number, duration: number): View {
  const span = view.to - view.from
  const from = Math.min(Math.max(0, view.from + ms), duration - span)
  return { from, to: from + span }
}

/** The view that shows `span` with a share of room either side, as narrow as the
 *  medium allows: what zooming to a selection or a claim asks. */
export function viewAround(
  span: Span,
  duration: number,
  minSpan: number,
  room = 0.1,
): View {
  const width = Math.max(minSpan, span.to - span.from)
  const pad = width * room
  return clampView(
    { from: span.from - pad, to: span.to + pad },
    duration,
    minSpan,
  )
}

/** The view moved just enough to hold `at`, a page at a time: past the right edge
 *  (playing on) the instant lands `lead` of the way in; before the left, the same
 *  from the right. Already inside, the view stays. */
export function viewFollowing(
  view: View,
  at: number,
  duration: number,
  lead = 0.1,
): View {
  const span = view.to - view.from
  if (at >= view.from && at <= view.to) return view
  const from = at > view.to ? at - lead * span : at - (1 - lead) * span
  return panBy({ from, to: from + span }, 0, duration)
}

/** px across `width` where `ms` falls in the view (outside the view, outside 0..width). */
export function xOf(view: View, width: number, ms: number): number {
  return ((ms - view.from) / (view.to - view.from)) * width
}

/** The instant at `x` px across `width`. */
export function instantAt(view: View, width: number, x: number): number {
  return view.from + (x / width) * (view.to - view.from)
}

/** ms one px of the width stands for. */
export const msPerPx = (view: View, width: number) =>
  (view.to - view.from) / Math.max(1, width)

// ── ticks ────────────────────────────────────────────────────────────────────────

/** Round durations a ruler steps by, ms, finest first. */
const MS_LADDER = [
  1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10_000, 15_000, 30_000,
  60_000, 120_000, 300_000, 600_000, 900_000, 1_800_000, 3_600_000, 7_200_000,
]
/** Frame counts a ruler steps by when the medium knows its rate, below a second. */
const FRAME_LADDER = [1, 2, 5, 10]

export interface Tick {
  /** ms */
  at: number
  /** A labelled division; the rest are the finer ones between. */
  major: boolean
  label?: string
}

export interface TickOptions {
  /** The medium's rate: below a second the ruler steps in frames and reads timecode. */
  fps: number | null
  /** A label's width per character, px (the ruler's type is tabular, so this is
   *  exact), and the room kept between two labels. */
  charPx?: number
  gapPx?: number
  /** The narrowest a finer division may be drawn apart, px. */
  minorPx?: number
}

/** A step as the ruler names an instant with it: a clock to the second, tenths or
 *  hundredths below it, timecode at frame steps. */
function labelOf(ms: number, step: Step, fps: number | null): string {
  if (step.frames && fps) return timecode(ms, fps)
  if (step.ms >= 1000) return clockTime(ms)
  const digits = step.ms >= 100 ? 1 : step.ms >= 10 ? 2 : 3
  const whole = Math.floor(ms / 1000) * 1000
  const fraction = ((ms - whole) / 1000).toFixed(digits).slice(1)
  return `${clockTime(whole)}${fraction}`
}

interface Step {
  /** ms between ticks (a frame step's is its frames' length) */
  ms: number
  /** stepped in frames: ticks fall on frame names */
  frames?: number
}

/** The steps a medium's ruler may take, finest first. */
function ladderOf(fps: number | null): Step[] {
  const frames = fps
    ? FRAME_LADDER.map((n) => ({ ms: (n * 1000) / fps, frames: n }))
    : []
  const below = frames.length ? 1000 / (fps as number) : 0
  return [
    ...frames.filter((s) => s.ms < 1000),
    ...MS_LADDER.filter((ms) => ms >= 1000 || !fps || ms > below * 10).map(
      (ms) => ({ ms }),
    ),
  ]
}

/** The instants a step falls on inside the view: whole multiples of it, so a tick
 *  never drifts as the view moves. A frame step counts within each second, as
 *  timecode does (frames 0, 5, 10, 15 of every second), and leaves out a last one
 *  closer to the next second than a whole step: a rate that is not a whole number of
 *  frames a second never squeezes two labels together. */
function stepsIn(view: View, step: Step, fps: number | null): number[] {
  const out: number[] = []
  if (step.frames && fps) {
    const every = step.frames
    // As `timecode` counts: a second's frame 0 is the one showing at the second
    // itself (it may have begun a hair before), so that tick sits on the second;
    // frame k after it on its own name.
    for (let s = Math.floor(view.from / 1000); s * 1000 <= view.to; s++) {
      const first = frameAt(s * 1000, fps)
      const count = frameAt((s + 1) * 1000, fps) - first
      for (let k = 0; k < count; k += every) {
        if (k > 0 && count - k < every) break
        const at = k === 0 ? s * 1000 : frameTime(first + k, fps)
        if (at > view.to) break
        if (at >= view.from) out.push(at)
      }
    }
    return out
  }
  const first = Math.ceil(view.from / step.ms) * step.ms
  for (let at = first; at <= view.to; at += step.ms) out.push(at)
  return out
}

/** The ruler at this view and width: major ticks labelled, never closer than their
 *  labels need, and the finer ones between where they have room. */
export function ticksOf(
  view: View,
  width: number,
  { fps, charPx = 6.6, gapPx = 18, minorPx = 6 }: TickOptions,
): Tick[] {
  assert(width > 0, `a ruler ${width} px wide`)
  const ladder = ladderOf(fps)
  const pxOf = (step: Step) => step.ms / msPerPx(view, width)
  // The widest label this view would draw at a step: its last instant's (a clock only
  // grows with time), every digit as wide as every other.
  const labelPx = (step: Step) =>
    labelOf(Math.floor(view.to), step, fps).length * charPx + gapPx
  const major =
    ladder.find((step) => pxOf(step) >= labelPx(step)) ??
    (ladder[ladder.length - 1] as Step)
  const majors = stepsIn(view, major, fps)
  // A finer step that divides the major one into whole parts and has room.
  const minor = ladder
    .slice(0, ladder.indexOf(major))
    .reverse()
    .find(
      (step) =>
        pxOf(step) >= minorPx &&
        (major.frames && step.frames
          ? major.frames % step.frames === 0
          : !major.frames && !step.frames
            ? major.ms % step.ms === 0
            : false),
    )
  const majorSet = new Set(majors)
  const minors = minor
    ? stepsIn(view, minor, fps).filter((at) => !majorSet.has(at))
    : []
  return [
    ...majors.map((at) => ({
      at,
      major: true,
      label: labelOf(at, major, fps),
    })),
    ...minors.map((at) => ({ at, major: false })),
  ].sort((a, b) => a.at - b.at)
}

// ── snap ─────────────────────────────────────────────────────────────────────────

/** `ms` pulled onto the nearest candidate (sorted ascending) within `radiusPx` of it
 *  at this view and width, else unchanged. Candidates are the instants a hand aims
 *  for: claims' ends, divisions, the playhead, the selection's ends. */
export function snapTo(
  ms: number,
  candidates: readonly number[],
  view: View,
  width: number,
  radiusPx = 6,
): { at: number; snapped: boolean } {
  const radius = radiusPx * msPerPx(view, width)
  let lo = 0
  let hi = candidates.length
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if ((candidates[mid] as number) < ms) lo = mid + 1
    else hi = mid
  }
  let best: number | null = null
  for (const i of [lo - 1, lo]) {
    const c = candidates[i]
    if (c === undefined) continue
    if (
      Math.abs(c - ms) <= radius &&
      (best === null || Math.abs(c - ms) < Math.abs(best - ms))
    )
      best = c
  }
  return best === null
    ? { at: ms, snapped: false }
    : { at: best, snapped: true }
}

/** `ms` on the name of the frame it falls in, where the medium knows its rate. */
export function onFrame(ms: number, fps: number | null): number {
  return fps ? frameTime(frameAt(ms, fps), fps) : Math.round(ms)
}