EpisodeTimeline

A zoomable episode-detail timeline: time ruler on top, a frame thumbnail strip beneath it, and N rows of labelled track blocks under that. The cursor is hover-driven — the user's pointer position is the single source of truth for the highlighted time. An optional time prop provides a fallback position to display when the pointer leaves (e.g., for syncing with a running video clock).

Pair with EpisodeVideoStack when you have multi-camera footage that should scrub against the same cursor.

Basic usage

Pure hover-driven: the cursor follows the pointer; when it leaves, the cursor disappears.

1.00×

Frames

frames is an ascending-time-sorted list of { time, image }. The component does not re-sort — sorting on every render would be wasteful and the contract is documented in the type. Frames at the same time deduplicate naturally because the greedy culler keeps the first one and drops any followers within frameWidth + 4px.

When frames is empty, the strip and its footer label collapse entirely — there's no empty band and no 0 OF 0 FRAMES readout; the track rows sit directly beneath the ruler.

Each frame's left edge sits at its time on the axis (fx = timeToX(f.time)) — the frame visually represents the chunk starting at time. This keeps the t = 0 cell fully visible at the timeline's left edge instead of half-clipped.

ts
type FrameSample = {
  time: number       // seconds; 0 ≤ time ≤ duration
  image: string      // URL
  alt?: string       // reserved for future a11y surfacing
}

const frames: FrameSample[] = [
  { time: 0,    image: '/keyframes/000.jpg' },
  { time: 0.5,  image: '/keyframes/030.jpg' },
  { time: 1.0,  image: '/keyframes/060.jpg' },
]

Greedy culling

When the visible window is wide (low zoom), many frames would overlap in screen space. The component sweeps left-to-right and renders a frame only when its time is at least (frameWidth + 4px) / pxPerSec later than the previous rendered frame. Off-screen frames are skipped.

As you zoom in, the visible time window shrinks and pxPerSec grows, so the stride between renderable frames shrinks too — more of the supplied frames qualify until eventually every one is shown. The footer readout {visible} OF {total} FRAMES updates live as you scrub.

Time positions are exact — each frame's left edge sits at the X corresponding to its actual time, no quantization to evenly-spaced slots.

Tracks and blocks

Each track is a row; each block is a labelled span { start, end, label }. Blocks outside the visible window are skipped entirely; blocks that straddle the edge render with a dashed border on the clipped side to signal "continues off-screen."

ts
type TrackBlock = {
  id?: string        // stable key for hover + click callbacks
  start: number
  end: number
  label: string
}

type TimelineTrack = {
  id: string
  name?: string      // accessibility only — not rendered
  blocks: TrackBlock[]
}

const tracks: TimelineTrack[] = [
  {
    id: 'phases',
    blocks: [
      { id: 'p1', start: 0,   end: 2.4, label: 'idle' },
      { id: 'p2', start: 2.4, end: 9.1, label: 'approach' },
    ],
  },
]

Provide stable id on each block to get reliable hover highlight + callback identity.

Interaction model

GestureEffect
HoverBright accent cursor pinned to the pointer X; highlights every track block whose [start, end] contains the cursor time AND the frame whose visual extent contains it. Fires onHover(time) on every move, onHoverEnd() on leave.
Click on empty areaonSeek(time) — separate "commit" event distinct from continuous hover.
Click on a blockonBlockClick(block, trackId) — does NOT fire onSeek.
Drag (≥ 4px)Pans the viewport (when zoomed in).
Shift + drag verticalDrag-zoom: drag up to zoom in, down to zoom out. Anchors the time under the cursor.
Wheel + alt/⌘/ctrlZoom at cursor (also fires on trackpad pinch — macOS dispatches ctrlKey + wheel).
Wheel (horizontal-dominant)Pan when zoomed in. Two-finger horizontal swipes are always swallowed, so a trackpad gesture on the timeline never triggers the browser's back/forward navigation.
ZoomBar / Step zoom by ×1.4 / ÷1.4.
ZoomBar drag readoutContinuous zoom via exp(dx × 0.008).

Controlled vs uncontrolled viewport

zoom and panPct are optional. Omit them → the component owns viewport state internally. Provide either one → the component flips into controlled mode for both (the omitted value falls back to its default, but the component no longer writes to its internal state). onViewportChange always fires when the user gestures, so the parent can persist the new values.

tsx
// Uncontrolled
<EpisodeTimeline duration={duration} time={t} onSeek={setT} />

// Controlled — persist viewport in URL / localStorage
const [vp, setVp] = useState({ zoom: 1, panPct: 0 })
<EpisodeTimeline
  duration={duration}
  time={t}
  onSeek={setT}
  zoom={vp.zoom}
  panPct={vp.panPct}
  onViewportChange={setVp}
/>
ts
type ViewportState = {
  zoom: number       // ≥ 1
  panPct: number     // 0 ≤ panPct ≤ 1 - 1/zoom
}

Design rules

User-visible behaviour the component enforces:

  • 0s strict left edge — the ruler picks tick steps from a fixed ladder (0.05, 0.1, 0.25, 0.5, 1, 5, 10, … seconds) and starts ticking at 0; the chart never shows negative time. The 0s and duration labels stay visible at the very edges instead of getting clipped.
  • Anchor zoom (or alt / ctrl) + wheel zooms about the cursor: the time directly under the pointer stays fixed across the zoom step.
  • Three-tier rulermajor / minor / micro. As zoom changes, ticks cross-fade smoothly between tiers instead of popping.

Props

PropTypeDefaultDescription
durationnumberEpisode duration in seconds. Must be > 0.
framesFrameSample[]Frame thumbnails, sorted ascending by time.
tracksTimelineTrack[]Track rows, drawn top-to-bottom in array order.
timenumber | nullnullFallback cursor position when the user is NOT hovering. Hover always overrides. null → pure hover-driven cursor that disappears on leave.
onHover(t: number) => voidCalled continuously on every hover move with the time at the cursor.
onHoverEnd() => voidCalled when the cursor leaves the timeline.
onSeek(t: number) => voidCalled on click in an empty area (single commit). Clicks on a track block fire onBlockClick instead and do NOT fire onSeek.
zoomnumber1Controlled zoom level. Clamped to [minZoom, maxZoom]. Pairing with panPct flips the component into controlled mode for both.
panPctnumber0Controlled pan as fraction of duration. Clamped to [0, 1 - 1/zoom].
onViewportChange(v: ViewportState) => voidFires when the user changes zoom or pan via wheel/drag.
onBlockClick(block, trackId) => voidClick on a track block (suppresses onSeek).
onBlockHover(block | null, trackId | null) => voidHover enter/leave on a track block. Receives null on leave.
framesLabelstring'FRAMES'Left-side uppercase caption under the frame strip. The right side is auto-generated as {visibleCount} OF {totalCount} FRAMES.
frameHeightnumberautoFrame thumbnail height in pixels (width auto from 16:9). When omitted, sized to fit 12 cells with 4px gaps.
trackRowHeightnumber28Track row height.
minZoomnumber1Minimum zoom factor.
maxZoomnumber20Maximum zoom factor.
classNamestringExtra classes on the root wrapper.