the window of time a timeline shows: zoom at a point, pan to the ends, ticks
npx shadcn add @ag/timeline-viewAlready installed? Preview an update
npx shadcn add @ag/timeline-view --dry-runYour 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
tracedraws its axis without the timeline.timelinebrings it as before.
the demo above, verbatim
"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
// 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)
}