Video Chapters
The chapter list beside a player: start times go in, every length, boundary and share of the runtime comes out derived, with the chapter holding the playhead marked, kept in view and filling a progress sliver.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/video-chapters.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VideoChapters" component: the chapter list
that sits beside a player, never the player itself. lucide-react for icons, zod
for the contract, one vendored shadcn Button for the retry affordance.
Contract
- The zod schema is the single source of truth and the props are z.infer of it,
not a parallel hand-written interface:
videoChaptersItemSchema = { id: string, title: string,
startSeconds: number, thumbnail?: string }
videoChaptersSchema = { status: "loading" | "empty" | "error" | "ready",
durationSeconds: number, currentSeconds: number,
chapters: VideoChaptersItem[] }
- A chapter carries a START AND NOTHING ELSE. There is deliberately no
endSeconds and no per-chapter duration: a consumer able to state both a start
and a length can state two numbers that contradict each other, and rendering
cannot repair that. Every end, every length and every share of the runtime is
DERIVED in-component, so the panel cannot disagree with itself.
- forwardRef<HTMLDivElement>; the root spreads every remaining native div prop
and merges className through cn().
- Extra props: onSeek?: (seconds, chapter) => void — fires with the DERIVED
(already clamped) start, so a negative start seeks to 0; maxHeight (default
320, "none" lets it grow); autoScroll (default true); skeletonRows (default 5,
clamped 1..12); emptyState?: ReactNode; errorMessage?: string; onRetry?;
labels?: Partial<VideoChaptersLabels> for every visible and spoken string.
Behavior — derivation is the whole product
- Normalise, in this order: drop non-finite starts (a chapter with no position
cannot be placed or seeked to); keep the first occurrence of a repeated id (a
seek handler cannot tell two of them apart); clamp starts into
[0, durationSeconds] rather than dropping them, so bad data still lists; sort
ascending — chapters arrive in author order, not clock order, and Array#sort
is stable per spec so equal starts keep their input order.
- end = the next chapter's start; the last chapter's end = durationSeconds.
duration = end − start. Nine chapters, nine derived lengths, one runtime.
- A first chapter that does not start at 0 leaves a LEAD-IN [0, firstStart): a
cold open nobody chaptered. Render it as its own muted, NON-interactive row —
it is part of the runtime so it takes its share, but the data says no chapter
covers it, so it is not a seek target, and a playhead inside it marks no
chapter at all: the row is still marked, because that is where the playhead
sits, but its spoken marker reads "playhead is here" and never "current
chapter" — the panel must not name a chapter its own data does not have.
Never round the first chapter down to 0 to make the gap go away; that is
inventing a chapter boundary the data never stated.
- Unknown media length is a real branch, not an error: HTMLMediaElement.duration
reads NaN until loadedmetadata and Infinity on a live stream. A non-finite or
non-positive durationSeconds means the last chapter has no end — show "length
unknown" for it, no share, and no progress sliver, instead of inventing a
figure. Every earlier chapter still derives normally from the next start.
- Marking: segments (lead-in + chapters) tile the media end to end, so the
marked row is simply the LAST one whose start is <= the playhead. The playhead
clamps into the media; a non-finite playhead means "position unknown" and
marks nothing, rather than defaulting to the first row.
- Share of runtime per row is integer, rounded by LARGEST REMAINDER (Hare
quota): floor every share, then hand the leftover points to the biggest
fractional parts, ties to the earlier row. The column totals exactly 100 —
naive rounding drifts to 99 or 101 and makes the panel contradict itself.
- Progress sliver: a thin bar along the bottom of the marked row only, filled by
(playhead − start) / duration, clamped 0..1. Zero-length or unknown-length
rows render NO sliver, because there is no fraction to state.
- Keep-in-view: an effect keyed on the MARKED ROW'S IDENTITY (not on the
playhead) scrolls the row back into the list's own scroller — minimal
scrolling plus an 8px margin, smooth unless prefers-reduced-motion. Never
element.scrollIntoView(): it scrolls every ancestor scroller too, dragging the
whole page around the player. Suspend it entirely while the pointer is inside
the list or a row holds KEYBOARD focus — gate that focus half on
:focus-visible, because browsers focus a <button> on click too, so a plain
onFocus would let the first click-to-seek freeze the follow behaviour for the
rest of the session — and use the blur relatedTarget so tabbing between rows
does not count as leaving; a list that yanks itself away mid-read is worse
than one briefly out of date.
- Four states are real branches: loading paints skeleton rows with the same
poster/two-line/timestamp rhythm (aria-hidden, aria-busy on the root, one
sr-only "Loading chapters" OUTSIDE the hidden subtree); empty is a panel, and
a "ready" list whose chapters all fail normalisation falls through to it;
error shows the message plus a retry Button only when onRetry is passed —
never a dead button; ready renders the list. The header summary
("9 chapters · 46:18") renders in ready only, so no state ever claims a count
or a runtime it does not have.
ARIA and keyboard
- Rows are real <button>s inside a real <ol role="list"> (list-style:none alone
strips list semantics in Safari/VoiceOver). Enter and Space are NOT
intercepted — the browser already turns both into a click, and handling them
again would seek twice.
- The marked row carries aria-current="true", NOT aria-selected: nothing here is
selected. The mark is a pure function of the playhead, so a listbox role would
promise a selection model the component does not have — and no roving
tabindex either: this is a table of contents, every entry is a tab stop.
- The row's accessible name is composed and stable: "Chapter 4, Out-of-order
chapters…, starts at 13 minutes 32 seconds, runs 4 minutes 53 seconds, 11% of
the runtime, current chapter". The lead-in row is a <div>, so it gets the same
composition through an sr-only sibling while its visible meta is aria-hidden
like every other row's: "Before the first chapter, runs 12 seconds, 0% of the
runtime, playhead is here". Durations are spoken as words everywhere, because
a screen reader reads "4:53" as a clock time, which a length is not. The
moving number
stays out of the a11y tree: the sliver is aria-hidden and there is no live
region, because announcing every chapter boundary would talk over the media
the user is listening to.
Rendering & styling
- Semantic tokens only: bg-card shell with a border, bg-muted posters and
skeletons, bg-accent for hover and the marked row, bg-primary for the sliver,
the left edge bar and the marked timestamp, text-muted-foreground for
secondary text, text-destructive for the error glyph, ring-ring for focus. No
hex, no rgb(), no oklch() — swap the tokens and it matches the host app, dark
mode included.
- Row: optional 16:9 poster (missing → film glyph, no network request; failing →
the same glyph after onError, keyed by URL so a replaced poster retries), a
two-line clamped title, a "4:53 · 11%" meta line, and the start timestamp
right-aligned like a table of contents page number. The poster column only
appears when at least one chapter has one.
- Motion is decoration: the sliver width transitions linearly behind
motion-reduce:transition-none, the skeleton pulse behind
motion-reduce:animate-none, and the keep-in-view scroll switches to "auto"
under prefers-reduced-motion. Nothing that matters is carried by animation.
- focus-visible:ring-2 ring-ring ring-inset on rows, so the ring is not clipped
by the scroller.
Customization levers
- Density: rows are px-3 py-2 around an h-9 w-16 poster. Drop the poster column
entirely (omit thumbnail everywhere) for a compact timestamp list, or grow it
to h-12 w-20 for a lecture sidebar; nothing is measured in JS.
- maxHeight is the whole scroller knob — "none" turns the panel into a plain
stacked list under a player, 320 keeps it beside one.
- Marking strength: the marked row combines four cues (accent fill, primary left
edge, play glyph, primary timestamp). Keep any subset; keep at least one that
is not colour alone.
- Shares: drop the "· 11%" half of the meta line if the audience does not care
about proportions — the derivation stays, the row just says less. Or move the
share into a right-hand column when the panel is wide.
- Tokens: recolour the sliver to var(--chart-1) when the page already spends
bg-primary elsewhere; the lead-in row's dashed placeholder follows border.
- i18n: labels is a complete Partial map (16 strings, including the
singular/plural chapter pair, the lead-in wording and the separate playhead
marker it uses instead of "current chapter"). The clock format
(m:ss / h:mm:ss) is deliberately locale-independent.
- Data: the contract is the seam. Add fields (speaker, slide deck anchor, a
badge) to the item schema and render them on the meta line — the derivation
only ever reads id, title and startSeconds.Concepts
- Derived durations — the contract has no duration field at all, so "starts 4:00" and "runs 5:00" can never contradict the chapter that starts at 8:00. Each chapter ends where the next begins, the last ends at the runtime, and the lengths, the shares and the sliver all read from that one arithmetic.
- The gap is data, not noise — a first chapter starting at 0:12 means twelve seconds nobody chaptered. The lead-in row shows them and takes its share of the runtime, but it is not a seek target and it marks no chapter, because rounding it away would invent a boundary the source never stated.
- aria-current, not aria-selected — nothing here is selected: the marked row is a pure function of the playhead, and the component owns no selection state. That is the line between this and a playlist listbox, and it is why there is no roving tabindex — a table of contents lets Tab reach every entry.
- Follow, don't fight — the keep-in-view effect is keyed on which row is marked, not on the clock, so it fires at chapter boundaries instead of sixty times a second; it moves the list's own scroller rather than
scrollIntoView, which would drag the page; and it suspends while the pointer or the keyboard is in the list. - Largest remainder — integer shares are floored and the leftover points go to the largest fractional parts, so the column adds up to exactly 100 in every render. Naive rounding lands on 99 or 101, and a panel that cannot add up its own numbers is not one you can ship to a buyer.
- Unknown length is a state, not a bug —
durationisNaNbeforeloadedmetadataandInfinityon a live stream, so the last chapter says "length unknown" and drops its share and its sliver rather than inventing a figure that would be wrong the moment metadata lands.
Pinned Scroll Gallery
A section that pins while its gallery travels sideways — vertical scroll drives a horizontal track of cards, with a progress rail, per-item snap points and a plain scroll-snap scroller when motion is off.
360 Spin Viewer
A turntable image sequence scrubbed by drag, touch and arrow keys — wrapping at both ends, preloaded behind a progress state, and skipping frames that fail.