Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Film, ListVideo, Play, TriangleAlert } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { VideoChaptersData, VideoChaptersItem } from "./video-chapters.contract"

const DEFAULT_SKELETON_ROWS = 5
const MAX_SKELETON_ROWS = 12
/** Breathing room kept above/below the marked row when it is pulled into view, in CSS px. */
const SCROLL_MARGIN = 8

export interface VideoChaptersLabels {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/video-chapters.json

Prompt

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 bugduration is NaN before loadedmetadata and Infinity on 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.

On This Page