Video Playlist
A queue that sits beside your player — thumbnail rows with duration and watched progress, a now-playing marker, cancellable autoplay-next, shuffle and repeat, and a keyboard-walkable listbox.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/video-playlist.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VideoPlaylist" component: the queue that
sits next to a player, never the player itself. lucide-react for the icons, zod
for the item contract, no media library and no popover library.
Contract
- The zod schema is the single source of truth and the props are z.infer of it,
not a parallel hand-written interface:
videoPlaylistItemSchema = { id: string, title: string,
duration: number (seconds), thumb?: string, progress: number (0..1),
watched: boolean }
videoPlaylistSchema = { status: "loading" | "empty" | "error" | "ready",
items: VideoPlaylistItem[] }
repeat = "off" | "all" | "one"
- forwardRef<HTMLDivElement>. The root spreads every remaining native div prop
and merges className through cn().
- Props extend that envelope with:
player?: ReactNode a SLOT. The component never renders a <video>,
never calls play(), never touches a media element.
label?: string default "Playlist"; names the panel and the list.
currentId?: string | null / defaultCurrentId?: string | null
onCurrentChange?: (id: string,
reason: "select" | "autoplay" | "skip") => void
playing?: boolean default true; drives the marker only.
endedId?: string | null the host's `ended` event, see Behavior.
autoplay / defaultAutoplay (true) / onAutoplayChange
autoplayDelay?: number seconds, default 5, clamped to >= 1 and rounded.
shuffle / defaultShuffle / onShuffleChange, shuffleSeed?: number (default 1)
repeat / defaultRepeat ("off") / onRepeatChange
maxHeight?: number|string caps the scroller; omit it and the list grows.
skeletonRows?: number default 4, clamped 1..12.
emptyState?: ReactNode, errorMessage?: string, onRetry?: () => void
labels?: Partial<VideoPlaylistLabels> every visible and spoken string.
- The four controlled/uncontrolled pairs share one tiny useControllableState
helper: controlled when the prop is not undefined (null counts as defined).
- Nothing reads the clock. A duration is absolute seconds and progress is a
fraction, so there is no instant for the server and the browser to disagree
about; the only time-dependent state is the countdown, which starts as null
and only ever ticks inside a client effect.
Behavior
- Ownership: the component owns the QUEUE — which item is current, what is next,
what happens when one ends. It never owns playback. Everything transport-shaped
leaves through onCurrentChange; `playing` and `endedId` come back in.
- Dedupe on the way in: a repeated id is dropped, first occurrence wins. Two rows
sharing an id cannot be told apart by any handler here.
- A row is: index (or the now-playing marker), a poster with a duration badge and
a watched progress bar, a two-line clamped title, and a "Watched" check or a
"45% watched" line. A missing thumb renders the film icon with no network
request at all; a thumb that fails renders the same icon after onError, and the
failure is keyed by URL so a replaced poster gets a fresh attempt.
- Degenerate data is a first-class case, not a crash: a non-finite, negative or
zero duration renders no badge and adds nothing to the header total; progress
is clamped to 0..1 so NaN reads as 0 and 1.4 cannot overflow the track;
`watched` wins over `progress` for the bar; a blank title falls back to
labels.untitled so a row is never a nameless click target.
- Shuffle permutes the PLAY ORDER only — the rendered rows never reorder under
the reader, and the footer is what reveals the difference. The permutation is
an FNV-1a hash of the id mixed with shuffleSeed, i.e. a pure function of the
data, because Math.random() here would hydrate a different "up next" than it
server-rendered. Bump shuffleSeed to reshuffle.
- Next resolution: repeat "one" hands the current id back (onCurrentChange fires
with the same id — that is the host's cue to seek to 0 and play again), repeat
"all" wraps at the end, repeat "off" has nothing after the last item, so no
countdown starts there at all.
- Autoplay-next: the host sets endedId when its <video> fires `ended`. An effect
keyed on that value alone reacts once per change — a stale id that is not the
current item is ignored, otherwise a countdown of autoplayDelay seconds starts.
Clear endedId (or write a fresh id) once you have handled it: a constant never
fires twice, exactly like a real player that has to play again before it can
end again.
- The countdown is one chained setTimeout keyed on the remaining second, so every
tick cancels the previous timer and unmounting cancels the pending one. The
panel offers Cancel and Play now, and Escape anywhere inside the component
cancels it (with stopPropagation, so a surrounding dialog does not close on the
same key). Turning autoplay off cancels it as well — a panel counting down to
something that will never happen is a lie.
- One-shot guarantee: a ref is read AND written synchronously by whoever closes
the panel (Cancel, Play now, expiry, or a row click landing in the same tick),
so the queue can never advance twice. Starting a countdown resets that ref.
- Focus is never dropped on <body>. The countdown panel is torn down under the
user in four different ways, so each path checks — before the unmount — whether
the panel really contains document.activeElement, raises a ref flag, and an
effect after the commit moves focus to the row that is now current. The retry
button does the same towards the panel itself, which is why the root carries
tabIndex={-1}. A focused row that leaves the queue hands off the same way. Two
guards stop this from becoming focus theft: the handoff only fires when focus
actually landed on <body>, and only when the playlist owned focus recently —
tracked by a focusin/focusout ref whose focusout keeps ownership when
relatedTarget is null, because "focus went nowhere" is exactly the case a
handoff exists for.
- Keyboard. The list is a listbox and a single tab stop (roving tabindex: exactly
one row has tabIndex 0 — the focused row, else the current one, else the first):
ArrowDown / ArrowUp one row, clamped, no wrap
Home / End first / last row
PageUp / PageDown five rows, clamped
Enter / Space play the focused row
printable characters typeahead over the titles, 600ms buffer, a repeated
letter cycles matches (WAI-ARIA listbox convention)
Escape cancel a running countdown
Enter and Space are deliberately NOT handled in the list's key handler: the
rows are real <button>s, so the browser already turns both into a click, and
handling them again would fire onCurrentChange twice.
- Selection does not follow focus. Arrowing moves the roving anchor only;
starting a video takes a deliberate Enter, Space or click, because "I am
looking at this row" and "play this now" are different intents.
- ARIA: role="listbox" labelled by the header title, aria-orientation vertical;
rows are <button role="option"> with aria-selected and a composed aria-label
("3. Designing tokens, 12 minutes 30 seconds, 45% watched, now playing"). The
visual innards of a row are aria-hidden so the label is the single spoken
truth. Autoplay and Shuffle are aria-pressed toggles; Repeat is a three-state
cycle button, so its new mode goes through the live region instead. One
permanently mounted polite role="status" carries the loading state, the
countdown ("Playing next in 5 seconds: <title>. Press Escape to cancel." —
fixed for the whole countdown, so it is announced once and not once per tick),
a repeat change, and whatever started playing.
- The native disabled attribute is never used: nothing here is inert, and the
controls that can vanish under the user use the handoff above instead.
- Four states are real branches, not afterthoughts: loading paints skeleton rows
with the same number/poster/two-line rhythm (aria-hidden, aria-busy on the
root); empty is a panel, and a "ready" queue with zero rows falls through to
it; error shows the message plus a retry button only when onRetry is passed —
never a dead button; ready renders the listbox and the footer.
Rendering & styling
- Semantic tokens only: a bg-card / text-card-foreground shell with a border,
bg-muted posters and skeletons, bg-primary for the marker, the watched bar and
the countdown bar, bg-accent for hover and for the current row,
text-muted-foreground for secondary text, text-destructive for the error
branch, ring-ring for every focus ring. No hex, no rgb(), no oklch() — swap the
tokens and the playlist matches the host app, dark mode included.
- cn() merges every className; focus-visible:ring-2 ring-ring on every button,
with ring-inset on the rows so the ring is not clipped by the scroller.
- Motion is decoration. The three-bar equalizer runs on one hoisted @keyframes
(React 19 <style href precedence>) behind motion-reduce:[animation:none]; the
countdown bar drains with a linear width transition behind
motion-reduce:transition-none, as do the colour transitions. With motion off
the marker is three static bars and the countdown still counts — nothing that
matters is carried by animation alone.
- The queue scrolls, the chrome does not: the player slot, the header and the
footer sit outside the scroller, so "Up next" stays visible under a long list.
Customization levers
- Density: rows are p-1.5 around an h-11 w-20 poster (16:9). Drop to h-9 w-16
with text-xs for a rail; nothing is measured in JS, so the layout just follows.
- Sub-blocks are independent: delete the footer to lose "Up next" without losing
autoplay; delete the toolbar and drive autoplay / shuffle / repeat from your
own chrome, since all three are controlled props; or drop the player slot and
render the playlist beside a player that lives elsewhere on the page.
- Countdown: autoplayDelay is the whole knob (5s is the familiar default). Swap
the draining bar for a circular ring, or remove the bar and keep the number.
- Ordering: shuffleSeed is the reshuffle handle — pass a seed your host generates
once per session for a per-session order. Want shuffle to reorder the visible
rows too? Render the computed play order instead of items; nothing else in the
logic cares.
- Tokens: recolour the watched bar to var(--chart-1) and the marker to
var(--chart-2) when the playlist has to match a chart, and keep bg-primary for
the countdown so the one urgent thing stays the accent.
- i18n: labels is a complete Partial map — around twenty strings, including the
"video" / "videos" pair and the untitled fallback. The clock format (m:ss and
h:mm:ss) is deliberately locale-independent, while durations are spoken as
words rather than as a clock, so a screen reader does not read 12:30 as half
past twelve.
- Data: the contract is the seam. Add fields (channel, uploadedAt, a badge) to
the item schema and render them on the second line of the text column — the
queue logic only ever reads id, title, duration, progress and watched.Concepts
- Queue, not playback — the component decides what plays next and hands the id out; the
<video>decides how. That split is what lets the same playlist sit next to a self-hosted player, a YouTube iframe or a native app shell, and it is whyplayeris a slot rather than asrcprop. - Cancellable autoplay — auto-advance without a visible, cancellable, keyboard-reachable countdown is a dark pattern; the panel spells out what is coming, in how many seconds, and gives you Escape, Cancel and Play now. Turning autoplay off kills the countdown with it.
- Deterministic shuffle — the play order is a hash of the ids, not
Math.random(), so the server and the browser paint the same "Up next" and hydration stays quiet.shuffleSeedis the reshuffle handle, and the visible rows never reorder — only the arrow into the next one moves. - Selection does not follow focus — arrowing walks the roving tabindex without starting anything; playback needs a deliberate Enter, Space or click. Browsing a queue and committing to a video are different intents, and a listbox that plays whatever you arrow past makes the keyboard unusable.
- Focus handoff on unmount — the countdown panel disappears under the user four different ways (Cancel, Play now, expiry, a data change), and a control that vanishes while focused drops focus on
<body>. Every path checks whether it really held focus, raises a ref flag, and an effect after the commit puts the caret on the row that is now playing. - Watched wins over progress — a finished video reads as a full bar and a check even when the last reported position was 0.97, while a
progressof 1.4 orNaNclamps instead of overflowing. The contract describes what an API should send; the component survives what it does send.
Pan Zoom
A pan-and-zoom viewport for content of known size — wheel, pinch and drag anchored under the pointer, a fit / actual-size toolbar, and a full keyboard path.
Avatar Upload
The whole avatar flow in one control — pick or drop, refuse with a spoken reason, crop square, upload with a progress ring and cancel, and keep the crop when the transfer fails.