npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@playdeck/react

v1.4.0

Published

Headless, composable React 19 media player primitives with capability-aware providers.

Readme

@playdeck/react

Headless, composable React 19 media-player primitives with one API across native MP4/WebM, HLS, YouTube, Vimeo and Wistia. No CSS is imported by the primitives, and every control is capability-gated: a control whose command the active provider cannot honour renders nothing rather than rendering disabled. One deliberate, named exception: LiveIndicator is an indicator rather than a control, so where its liveEdge command is unavailable it stays mounted as a non-interactive disabled badge instead of vanishing. Being live is true regardless of whether a seek-to-edge command exists. See the docstring above LiveIndicator for the reasoning.

pnpm add @playdeck/react

React 19 is a peer dependency, and only React 19 (the range is >=19 <20), so a React 18 or React 20 project cannot install this package. Provider packages are pulled in as dependencies but loaded lazily: a consumer playing only MP4 ships no YouTube, Vimeo, Wistia or hls.js code in its initial graph, and makes no provider network requests.

This package's entry carries a 'use client' directive, so a React Server Component can import these primitives and render them directly, with no wrapper of your own to hold the boundary. It is still a boundary: the props a server component hands across it are serialised, so a function prop (a renderCue, an onVolumeChange) belongs on a component of yours that carries the directive itself.

@playdeck/core and the provider packages import no React API and carry no directive; server code can call detectSource and the rest of that surface without a boundary at all.

The guides at playdeck.video/guides carry the full styling contract (Contract), the caption guidance (Captions), the quality-selection guidance (Quality), the playback-rate guidance (Playback rate), the chapters guidance (Chapters), the audio-track guidance (Audio tracks) and the live-playback guidance (Live). Every primitive below is also staged, running, in this repository's Storybook workbench, which is a development tool rather than a published surface.

Usage

import * as Player from '@playdeck/react';

export const Clip = () => (
  <Player.Root source="https://example.com/clip.mp4">
    <Player.Viewport>
      <Player.Media
        textTracks={[
          { src: '/captions.en.vtt', srcLang: 'en', label: 'English' }
        ]}
      />
      <Player.Poster>
        <Player.PosterImage alt="" src="/poster.jpg" />
      </Player.Poster>
      <Player.Captions />
      <Player.Controls>
        <Player.PlayButton />
        <Player.MuteButton />
        <Player.VolumeSlider />
        <Player.SeekSlider />
        <Player.Time type="current" />
        <Player.Time type="duration" />
        <Player.CaptionsButton />
        <Player.PipButton />
        {/* Renders only where there is somewhere to cast to. */}
        <Player.AirPlayButton />
        {/* Renders only where a Remote Playback API device is reachable. */}
        <Player.RemotePlaybackButton />
        <Player.FullscreenButton />
      </Player.Controls>
    </Player.Viewport>
  </Player.Root>
);

source takes the same input as detectSource: a URL string, or an explicit { type: 'hls' | 'video' | 'youtube' | 'vimeo' | 'wistia', ... } object. See Provider setup for which URL forms each provider accepts, which it refuses, and what each provider's providerOptions are.

loading chooses when the source commits and the provider attaches: 'viewport' (the default), 'eager', or 'interaction'. 'viewport' commits once Player.Viewport crosses loadThreshold. Under 'eager', a video file source commits at render time rather than waiting for an effect, so the native <video> and its <source> children (preload included) are in the server HTML too, and the browser can start the request while it parses the page, before hydration or any script runs. HLS and the three embed providers (YouTube, Vimeo, Wistia) still commit from an effect, client-side only. Under 'eager', if Player.Viewport has no layout box at all on hydration (display: none, for a player hidden at some breakpoint), commit holds there (no autoplay attempt, no provider load) until the viewport gets one; a resize across a breakpoint then continues exactly as an eager player that was visible from the start. That check is getClientRects().length === 0, which catches display: none but not visibility: hidden or content-visibility: hidden. Both still generate a box. Either way, the server can't know about CSS: a video file source's own request already left with the HTML parser before any of this runs. Prefer loading: 'viewport', or leave priority unset, for a player that may be hidden at some breakpoint. 'interaction' commits on the first Player.ActivationButton press, or a direct activateFromInteraction call, and cannot be combined with autoplay. Autoplay itself is unaffected by any of this: the server can't evaluate prefers-reduced-motion, so the server HTML never carries an autoplay attribute, and the controller still attempts play() only after the provider attaches on the client, whatever loading is set to.

priority is a shorthand for the player above the fold, for a consumer who would otherwise set loading, preload and a poster's own fetchPriority by hand to get there -- see loading above for what an eager player does when hidden by CSS, which applies here too since priority is exactly the prop that opts a player into eager without writing loading at all. Set true on a Root whose source resolves to a video file, it implies loading: 'eager' and preload: 'auto' where either is left unset (an explicit loading other than 'eager' is a configuration error instead), carries fetchPriority: 'high' and loading: 'eager' onto the poster Player.Poster resolves from poster unless the poster already sets either itself, and renders a <link rel="preload" as="image"> element for that same poster -- React 19 hoists a rendered <link> into <head> on both the server and the client, so the server HTML carries it too. Defaults to false, which changes none of those defaults. Setting priority on more than one player sharing a poster renders one link per player -- React only dedupes a <link rel="stylesheet"> by its own precedence mechanism, never a preload.

textTracks is a prop on Player.Media, not on Player.Root. It takes an array of { src, srcLang, label, kind?, default? }, where kind is 'captions' or 'subtitles' and default selects that track on load. Tracks you supply this way reach native playback and the HLS provider's native engine; everywhere else only the captions a provider discovers for itself are available. Selection, discovery and what Player.Captions renders are covered in Captions.

Player.Captions draws the active cues only where the provider hands caption rendering over, and renders nothing otherwise: where the provider paints its own captions there is nothing for it to add. renderCue replaces what it draws for each cue:

renderCue?: (cue: TextCue) => ReactNode;
// TextCue: { id: string | null; startTime: number; endTime: number; text: string }

A cue is stripped to those four fields before it reaches your function, so no provider-specific field leaks into consumer code. Supplying renderCue also drops the default styling from each cue's own box, leaving its appearance to you; the overlay that positions the cues keeps its own. Reach for it when the --playdeck-caption-* tokens do not go far enough, or to render a cue as something other than a run of text. The default splits text on newlines into one caption-line part per line.

captionRenderer on Player.Root chooses which side draws the cues, and defaults to 'custom': the provider hands its cues to Playdeck and Player.Captions paints them. 'native' asks the provider to draw its own captions instead (a request rather than a setting), because what a provider has to hand them to differs. The answer is state.captionRendering, and that is what Player.Captions follows: it draws while that reads custom and renders nothing otherwise, whatever the prop says.

What the request can reach differs by what is playing. Where the browser holds the media itself, asking for 'native' puts the selected track back into its hands and the state reads native while a track is selected. Where an embed paints cues inside its own frame, the state reads provider and the prop changes nothing: there is no second surface to hand them to. Where a provider parses cues itself without a native seam, the request lands and the state honestly stays custom. In every case the state reports what is really drawing, not what was asked for.

unavailable is the answer before anything has reported otherwise. A source whose provider found no caption tracks reads that way, and so does one whose provider has no caption seam at all: it is the value the state starts at, so nothing having patched it and nothing being there are indistinguishable from outside.

Two more Player.Root props choose a caption track without a consumer calling selectTextTrack itself: preferredTextTrackLanguage?: string and defaultTextTrack?: 'auto' | 'off'. preferredTextTrackLanguage is a BCP 47 language tag; once a source's tracks publish, and again whenever captions turn on without the viewer having picked a track of their own, selection resolves in this order: an exact TextTrack.language match, then the same base language (en matches en-GB), then the provider's own default track if it has one, and otherwise the first captions/subtitles track. Matching is case-insensitive, and a track whose language is null never matches either language step (it remains eligible for the two fallback steps). defaultTextTrack defaults to 'auto', which leaves that resolution (and, with neither prop set, the provider's own initial pick) alone; 'off' starts every source, YouTube included, with no track selected. Either prop stops acting the moment the viewer makes their own choice, through a control or selectTextTrack, for that source; a new source applies both again.

preferredTextTrackLanguage is checked against a BCP 47 tag's shape (letters, digits and hyphens, up to 35 characters) before it is used anywhere. A value that fails is ignored outright (selection behaves exactly as if the prop were absent), and a configuration-category notice naming the rejected value is published on PlayerState.error instead of being thrown.

On YouTube, defaultTextTrack="off" also writes the embed's own cc_load_policy=0 player var, so a default caption track is never requested in the first place. This has not been verified against a real YouTube player: YouTube's own docs document cc_load_policy=1 as forcing captions on, but give 0 no documented meaning beyond matching the var's own absence, so this repo's coverage of it is a fake iframe API honouring the var by construction, not a measurement of real YouTube's response to it.

Optional stylesheet with the default look:

import '@playdeck/react/theme.css';

Reading state and issuing commands

import * as Player from '@playdeck/react';

// `usePlayerState` takes a selector and re-renders only when the selected
// value changes, not on every time update.
export const PlaybackLabel = () => {
  const playback = Player.usePlayerState((state) => state.playback);
  return <span>{playback}</span>;
};

// Commands are answered: `whenReady` tells you when one will land, instead of
// issuing it and hoping.
export const SlowMotionButton = () => {
  const actions = Player.usePlayerActions();
  return (
    <button
      onClick={async () => {
        if (await actions.whenReady()) await actions.setPlaybackRate(0.75);
      }}
    >
      0.75×
    </button>
  );
};

// The cues active right now, for rendering captions yourself.
export const CueText = () => {
  const cues = Player.useActiveCues();
  return <p>{cues.map((cue) => cue.text).join(' ')}</p>;
};

usePlayerState takes a selector and re-renders only when the selected value changes. Player.Root also accepts a ref, which receives a PlayerHandle carrying the same commands plus getState, subscribe and on.

The commands that ask a provider to do something resolve a CommandResult: { ok: true }, or { ok: false, reason } with an optional PlayerError behind it. Not every member is one: whenReady answers a boolean, setCaptionRenderer records a preference and returns nothing, and activateFromInteraction starts a player rather than issuing a command.

That result is the only place the answer lives, because nothing is queued and replayed once the player catches up: a reason of not-ready means the call did nothing and the caller has to ask again, which is what whenReady is for. The reason vocabulary and the capability contract are shared with the controller and are documented in @playdeck/core.

seekBy moves the playhead by an offset in seconds: negative back, positive forward. It is not sugar for seekTo(currentTime + offset): the offset is added to the position the provider itself holds when the command reaches it, so it does not inherit the lag between a provider's real position and the currentTime a render last read. Every provider clamps the target into the same window seekTo lands in, so overshooting either end of the media is not itself a failure: the seek lands on the boundary. An offset that is not finite is a failure, answered provider-error without the playhead moving. This is the command behind Controls' arrow, j/l and PageUp/PageDown bindings, and behind Gestures' seekOffset.

toggleMuted reads the muted state the player is in and issues mute or unmute accordingly, resolving whatever that one command resolved, so a false result is the mute or the unmute failing, not the toggle. It writes no volume of its own, which is why unmuting a player whose published volume is 0 leaves it silent; the arrow-key handling described under Controls is a deliberate exception layered on top, not what the command does.

activateFromInteraction is on both surfaces and is not a PlayerController method, which is worth saying because it is where a reader looks for it first. It starts a player that loading="interaction" has left dormant (nothing fetched, no provider network request made), and PlayerController has no concept of that dormancy: only the activation Player.Root runs has one. That dormancy is also why poster="provider" never shows anything before the click: it resolves from the attached provider, and nothing attaches here until one. See A poster before the provider attaches in Provider setup for a poster that does not need the wait: a pure helper for YouTube, and the build-time pattern for Vimeo and Wistia. Joining it to the handle is what lets an external control surface drive activation through the ref it already holds, without the controller growing an activation concern of its own. It returns void rather than a CommandResult, because it starts a player rather than issuing a command; what became of the activation is read from state.activation and state.error.

Calling it on a dormant root arms the player and queues one play, so the provider loads and playback begins from that single call. Player.ActivationButton is the bundled way to make that call, not the only one. Where the activation is in its error state it is a retry instead: the provider is discarded and the load re-armed. It refuses an error the state reports as recoverable: false, exactly as ActivationButton does, so a direct call and a click turn down the same errors.

Everything else is a silent no-op, which is the shape an external controller that calls it unconditionally before play() needs: a root that is not loading="interaction", a source that was refused, the loading="interaction" with autoplay pairing the library treats as a configuration conflict, and a player that has already activated all leave it doing nothing rather than restarting or throwing. Calling it moves the root to eligible at once, but a provider is not attached yet, so a play() issued straight after resolves { ok: false, reason: 'not-ready' } and is dropped rather than queued (nothing is replayed here either), so that pair costs exactly one play once the provider attaches.

Volume, muting and playback rate

Each playback preference is either yours to hold or the player's. muted, volume and playbackRate are the controlled props: pass one and the player is pinned to that value, and moving it is yours to do. defaultMuted, defaultVolume and defaultPlaybackRate are the uncontrolled form: a starting value the player then owns, which is what opening muted, or opening quiet, costs when you have no reason to hold the state yourself.

| uncontrolled | controlled | starting value | | --------------------- | -------------- | -------------- | | defaultMuted | muted | false | | defaultVolume | volume | 1 | | defaultPlaybackRate | playbackRate | 1 |

Each row is decided on its own, so a player can be controlled on one preference and uncontrolled on another. Within a row the controlled prop wins outright wherever it holds a value: the default* beside it is never consulted, and it is not a fallback for a controlled prop that is momentarily undefined, since an undefined there is the whole row going uncontrolled.

A default* is read on the first render and never again, the same as React's own uncontrolled inputs. What it seeds is the first media that attaches; from there the value the provider last confirmed is what carries forward, through a source change and through a provider re-attach, rather than the default being applied a second time. Handing a controlled prop back likewise leaves the player on the value it currently holds rather than returning it to the default. A starting volume is clamped into 0–1. A volume that is not finite is not applied at all, and neither is a playback rate unless it is finite and greater than zero.

onMutedChange, onVolumeChange and onPlaybackRateChange report the value the provider confirmed rather than the one that was asked for, which is why they are worth reading even where you issued the change yourself. They fire whatever moved the player: a bundled control, a usePlayerActions or PlayerHandle command, the provider's own chrome under controls, the platform moving the media by itself.

What they suppress is the echo: a change Player.Root issued to satisfy a controlled prop is not reported back, so a controlled player is not called with the value it was just handed. A starting value the provider agrees with is not announced either. Neither rule is a guarantee that no callback ever arrives unbidden, because both compare against what was asked for: a provider that confirms something else answers with a genuine change. A controlled volume outside 0–1 is the reachable case: it travels to the provider as given, and the value that comes back is the clamped one.

That makes them the other half of a controlled player. A change arriving from anywhere but the prop is reported and then reconciled back to the prop's value, so a controlled player whose callback updates no state snaps its viewer back every time they touch the control. Under the uncontrolled form the callback observes only: the player keeps the new value with or without it. Each is read through a ref as Player.Root renders, so an inline arrow function re-subscribes nothing.

Media Session

Player.Root binds itself to the browser's Media Session, so a player composed from these primitives reaches the platform's own media surface (lock screen, notification shade, hardware keys) without a consumer calling anything. mediaMetadata is what it publishes there: an optional { title, artist, album, artwork }, where artwork is a list of { src, sizes?, type? }. Nothing is read off the source to fill it, so passing none publishes none rather than a guess, and changing the prop on a mounted player replaces what that player publishes. Where the browser exposes no navigator.mediaSession there is nothing to bind and the prop does nothing.

That surface is shared by every player on the page, and the arbitration over it (which root owns it, when ownership moves, and which of the platform's actions are routed back into playback) belongs to bindMediaSession and is documented under Media Session in @playdeck/core. Player.Root makes that call for you, and mediaMetadata is both its seed and its setter; what the platform shows is still whatever the root owning the surface published last.

An artwork src is a consumer-supplied URL, so the allowlist can refuse one. What that publishes is A URL prop the allowlist refused, also in @playdeck/core.

Exports

Structure

Root, Viewport, Media, Poster, PosterImage, ActivationButton, LoadingIndicator, ErrorDisplay, Captions, Gestures, LiveIndicator.

Each overlay renders only when its own state calls for it. Nothing is drawn disabled, with one exception: LiveIndicator stays mounted where its liveEdge capability is unavailable, as a disabled badge rather than an active button, for the reason given beside the capability-gating rule above (see the comment in the example below). LiveIndicator's data attributes, PlayerState.live and which providers can report it are covered in Live:

import * as Player from '@playdeck/react';

// The overlay layers, in the order they stack inside a Viewport. Each renders
// only when its own state says it should: no disabled-looking placeholders,
// with one exception noted below.
export const Overlays = () => (
  <Player.Viewport>
    <Player.Poster>
      <Player.PosterImage alt="" src="/poster.jpg" />
    </Player.Poster>
    <Player.Media />
    {/* Deferred loading: the button activates the player on first interaction. */}
    <Player.ActivationButton aria-label="Play" />
    <Player.LoadingIndicator />
    <Player.Captions />
    {/* Renders only on a live source -- nothing while `state.live` is null.
        The one exception to "no disabled-looking placeholders" above: where
        the provider cannot seek to the live edge it stays mounted as a
        disabled badge, because being live is true whether or not that
        command exists. Where the provider can, it is an active button. */}
    <Player.LiveIndicator />
    <Player.Gestures
      seekOffset={10}
      onSeek={(direction, offset) => console.log(direction, offset)}
    />
    {/* `retry` is null when the error cannot be retried: absent, not
        disabled. */}
    <Player.ErrorDisplay>
      {({ error, retry }) => (
        <div role="alert">
          {error.message}
          {retry ? <button onClick={retry}>Retry</button> : null}
        </div>
      )}
    </Player.ErrorDisplay>
  </Player.Viewport>
);

// `Poster` accepts a URL string, an image-props object, or your own element.
// `normalizePoster` is the same resolution the primitive performs.
export const poster = Player.normalizePoster('/poster.jpg');

Controls

PlayButton, MuteButton, VolumeSlider, SeekSlider, Time, FullscreenButton, PipButton, AirPlayButton, RemotePlaybackButton, CaptionsButton, Controls.

Presentation and casting

FullscreenButton, PipButton, AirPlayButton and RemotePlaybackButton each read one entry of state.capabilities (fullscreen, pictureInPicture, airPlay and remotePlayback) and render only while that entry says available. An unknown entry renders nothing either: a capability still being decided is not a reason to put a control on screen and then withdraw it.

RemotePlaybackButton opens the browser's own Remote Playback picker (the standards-based route to Chromecast and other receivers, reached through the media element's remote object rather than the Cast SDK) and is available only once that element reports a device actually reachable on the network.

Driving those presentations without the buttons means doing that gate yourself. The commands are on PlayerHandle and on usePlayerActions, as the request and exit pairs requestFullscreen/exitFullscreen and requestPictureInPicture/exitPictureInPicture. The built-in buttons choose which half of a pair to send from state.fullscreen and state.pictureInPicture, and that choice is exactly what you take over. showAirPlayPicker and showRemotePlaybackPicker have no exit twin and are not toggles: each opens its platform's own route picker, and which device the viewer picked (or whether they picked one at all) is never reported back, which is why neither button carries state of its own. remotePlayback's connection state is published separately, as PlayerState.remotePlayback, for a consumer who wants to render it.

Calling one past its gate is answered rather than thrown, and the CommandResult says which gate it met. not-ready is a command that arrived before a provider was attached and ready to take it. unsupported is the active provider having no such command to give: an embed exposes only what its own SDK offers, so some wire no picture-in-picture at all, and the AirPlay and remote-playback pickers are wired only by the adapters that drive a media element directly, and then only where that element exposes them. blocked is a permissions policy or a media-element attribute refusing it, and carries the PlayerError that names which. So the capability answers whether to offer a control, and the result answers what became of a command once it was issued.

Accessible names

Every interactive control ships a built-in English aria-label, and an aria-label you pass always wins over it. The built-in is a fallback, never an override, so <Player.PlayButton aria-label="Reproducir" /> and <Player.SeekSlider aria-label="Buscar" /> do the same thing for the same reason. Playdeck carries no message catalogue and no locale handling; this prop is how you supply your own strings. (Time and Controls are not interactive and carry no name of their own.)

A button's visible text is separate, and you own that too. The buttons fall back to rendering their own English wording as their children, so naming a button without also passing children leaves the two disagreeing: a <Player.PlayButton aria-label="Reproducir" /> reads "Reproducir" to a screen reader and "Play" on screen. That is a WCAG 2.5.3 Label in Name failure for anyone driving the control by voice. Pass both, or pass an icon as children and let the name stand alone.

Where a control's own label changes with its state (play/pause, mute/unmute, captions on/off, the fullscreen and picture-in-picture toggles, and LiveIndicator's "Live"/"Go to live"), one name you supply holds in every state. The library does not reassert its own wording in one state and keep yours in the other: naming the control is yours from the first prop onwards, so pick a name that reads correctly in both, or drive it yourself from usePlayerState.

SeekSlider is the one control whose props are the wrapper <div>'s rather than the interactive element's, because it renders buffered geometry around the input. aria-label is the single exception, and is forwarded onto the inner <input type="range">, the element that carries the slider role and the one a screen reader announces. Everything else you pass stays on the wrapper, where className, style and data-* belong.

inputProps is the escape hatch onto that inner input, for the props that have nowhere else to go: step, disabled, id/name, onChange, its own style, data-*. It is the more specific of the two, so inputProps['aria-label'] outranks a top-level aria-label, which outranks the built-in "Seek". Playdeck keeps ownership of the controlled attributes: value, min, max, type, aria-valuetext, aria-disabled, so those cannot be overridden. An onChange you pass is chained after the seek rather than replacing it, and an aria-describedby you pass is composed with the buffered-progress description rather than replacing it.

thumbnails takes the URL of a WebVTT file whose cues carry an image URL with a #xywh= sprite-region fragment (the convention Vidstack, Media Chrome and Video.js all read), so an existing sprite-generation pipeline needs no change. The file is fetched once, lazily, on the first hover or the first keyboard focus of the input (never at mount), and, once loaded, SeekSlider renders a thumbnail part cropped to the cue for the pointer or focus position, above the track. Without the prop, nothing extra renders. A relative cue image URL (the ordinary output of a sprite generator) resolves against the VTT file's own final address, not the page's, before it ever reaches the allowlist below, so the allowlist judges the same address the <img> will actually request (@playdeck/core's parseThumbnailCues). Every cue's image URL passes through the same allowlist every other URL in the player does; a refused thumbnails URL or a refused cue image publishes the same A URL prop the allowlist refused notice mediaMetadata's artwork does. The part's name and its data-state values are documented in the Contract guide.

The preview is code you pay for only when you ask for it. The cue fetch, the cue lookup, the crop geometry and the thumbnail part all live in a module SeekSlider imports through a dynamic import(), started when the prop is present, so a seek slider without it downloads none of them: the same trade Player.Root makes for provider adapters. The part therefore appears once that module has loaded rather than in the same frame as the slider; it is absolutely positioned and starts hidden, so nothing moves when it does, and a hover or focus that happens first is previewed as soon as it arrives.

Time takes a type of current (the default), duration or remaining. remaining counts down from the duration and carries a leading minus for as long as any remainder is left: -1:23, and still -0:00 through the last second before the end. Only an exhausted remainder reads 0:00. Each instance carries data-time-type, so the three are styleable apart.

On a live source, current reports distance from the live edge rather than elapsed time: a negative offset while playback is behind it (-0:42), and the word LIVE once it is within the edge's tolerance. liveLabel sets that word, for a consumer translating the interface; it defaults to LIVE. duration has no total to report on a live source and so renders no time at all, leaving the data-state="untimed" hook below in its place.

data-state="untimed" marks a Time on a source with no duration to measure against: a live stream, or one whose duration has not arrived yet. It marks all three types, current included, because it describes the source rather than the instance. What differs is the element. duration and remaining have no time to mark up there, so each becomes a <span>: keyed on the source being untimed, not on the text coming out empty, so one given children is still a <span> and still displays them. Given none it renders nothing: the library draws no placeholder of its own, because a 0:00 there would state a zero-length video rather than an unmeasured one. current still has an elapsed time to show, so it stays a <time>. Pair the state with data-time-type to hang a LIVE badge or an em dash off the right one: the state alone also matches the running current beside it.

Controls is a focusable region that owns the media keyboard shortcuts: Space and k toggle playback, ArrowLeft/ArrowRight seek 5s back and forward, j/PageDown seek 10s back and l/PageUp 10s forward, ArrowUp/ArrowDown move the volume by 0.05, and m, f and c toggle muted, fullscreen and captions. Every binding except togglePlayback is gated on the capability its command needs, and an unavailable one is inert: the key acts on nothing and keeps none of it, so it is left to the page. togglePlayback is ungated, because there is no playback capability to gate on: Space and k are taken on every provider, subject only to the target rules below. The layer fires only while focus is inside the region; global attaches the same map to the document instead.

While the player is muted the volume arrows act on the muted zero the control is showing rather than on the published volume behind it: ArrowUp unmutes and restores that published level unchanged (stepping to 0.05 only where the published volume is itself 0, which unmuting alone would leave silent), and ArrowDown does nothing, because the player is already silent. ArrowDown still keeps the key. The thumb moves to the restored level at once, while the player is still muted, so the next press compounds on it: muted at 0.5, ArrowUp ArrowUp lands on 0.55. Both arrows do step a change the player has not answered yet, because that is what the thumb is showing: muted and dragged to 0.3, ArrowDown moves to 0.25. VolumeSlider's own pointer, Home and End changes are unaffected: those start from the zero on screen, so moving one up unmutes at the value the user chose.

The region owns those keys wherever focus sits inside it, a focused <input type="range"> included, so the arrows seek and adjust volume at the same distances on SeekSlider and VolumeSlider as off them: see ADR-0005. Text entry (a text <input>, <textarea>, <select> or content-editable region) still swallows every key, and a focused button, link or checkbox keeps Space and Enter for itself.

In global mode, an arrow key leaves a widget outside the player alone when that widget answers arrows itself: a native radio or range input, or anything inside an element carrying one of the WAI-ARIA composite-widget roles that navigate with arrows: radiogroup, tablist, slider, spinbutton, listbox, menu, menubar, tree, treegrid, grid or toolbar. Neither the bound shortcut nor preventDefault() runs there, so a radio group, a tab strip or a slider elsewhere on the page keeps its own roving-focus navigation instead of losing it to volume or seek. The exemption only ever applies outside the player: Player.Viewport's own DOM node where the region renders inside one, this region's own node otherwise. An arrow key still belongs to the layer wherever focus sits inside that boundary, its own sliders included and a consumer's own control composed elsewhere in the same viewport included, and still fires normally on any other page content.

PageUp and PageDown are in-region only in global mode: outside that same boundary the layer does not handle either key and does not call preventDefault(), on any target (<body>, a plain scrollable element, a grid or tablist, or anything else), so the page keeps its own paging. Unlike the arrow exemption above, this one does not check what the target is; native paging outranks the shortcut everywhere outside the player. The rule follows the two keys themselves rather than the actions bound to them, so shortcuts={{ seekForwardLarge: 'l', toggleFullscreen: 'PageUp' }} (which frees PageUp from the default seekForwardLarge binding before handing it to toggleFullscreen) keeps that in-region-only behaviour for the rebound key: outside the boundary it still does nothing, and inside it it toggles fullscreen instead of seeking. PageUp/PageDown at their defaults seek inside the boundary exactly as described above, and scoped mode is unchanged either way.

shortcuts controls the layer. shortcuts={false} turns it off entirely: in global mode no document listener is attached at all. An object is a partial override map of action to a KeyboardEvent.key value, an array of them, or null to suppress that one binding; every action it does not name keeps its default, so moving one key never means restating the map. Both forms behave the same in either scoping mode. WCAG 2.1.4 Character Key Shortcuts requires that of global mode, whose keys are live wherever focus is on the page; the region-scoped default conforms through the active-on-focus exception.

shortcuts={{ seekBackward: null, seekForward: null }} suppresses the two seek bindings and hands the arrows back to whatever native control has focus. ShortcutAction names the ten actions and ShortcutBindings is the map type. Hoist the object or useMemo it: a fresh literal on every render re-attaches the global listener.

Menus

SettingsMenu, SettingsMenuTrigger, SettingsMenuContent, MenuItem, MenuRadioGroup, MenuRadioItem, CaptionsMenu, QualityMenu, PlaybackRateMenu, ChaptersMenu, AudioTrackMenu.

SettingsMenu and the menu parts are also the building blocks the reference example below composes quality and playback rate from by hand, alongside one another, for a consumer who wants both under one trigger. QualityMenu, PlaybackRateMenu and ChaptersMenu are the standalone primitives for a consumer who wants one on its own, the same way CaptionsMenu is for captions, covered in Quality, Playback rate and Chapters. AudioTrackMenu is the same shape for state.audioTracks, covered in Audio tracks. It lists a rung per published track and marks whichever one carries active: true. There is no "Auto" row and no sibling selection field, since an audio track carries its own selection (see the Audio track glossary entry in GLOSSARY.md).

import * as Player from '@playdeck/react';

/**
 * The label a quality rung prints: its height. Not a promise the *library*
 * makes about every provider -- `PlayerQuality`'s `height` field is nullable
 * -- so the rung's own id stands in for the one entry that carries none. The
 * bitrate is not printed here; the stats readout under the player already
 * shows it.
 */
const qualityLabel = (quality: {
  readonly id: string;
  readonly height: number | null;
}): string => (quality.height === null ? quality.id : `${quality.height}p`);

// A settings menu built from the menu parts: quality, then playback rate.
// `SettingsMenu` owns the open state and returns focus to the trigger on
// every close path.
export const RateMenu = () => {
  const actions = Player.usePlayerActions();
  const { rate, qualityStatus, qualities, selectedQualityId } =
    Player.usePlayerState((state) => ({
      rate: state.playbackRate,
      qualityStatus: state.capabilities.selectQuality.status,
      qualities: state.qualities,
      selectedQualityId: state.selectedQualityId
    }));

  return (
    <Player.SettingsMenu>
      <Player.SettingsMenuTrigger aria-label="Settings" />
      <Player.SettingsMenuContent>
        {/* Gated the same way the library gates its own controls: absent
            where the provider will not honour `selectQuality`, never present
            and empty -- a source with no ladder to choose from (YouTube)
            prints no group at all. */}
        {qualityStatus === 'available' && qualities.length > 0 && (
          <Player.MenuRadioGroup
            aria-label="Quality"
            value={selectedQualityId ?? ''}
            onValueChange={(value) =>
              void actions.selectQuality(value === '' ? null : value)
            }
          >
            <Player.MenuRadioItem value="">Auto</Player.MenuRadioItem>
            {qualities.map((quality) => (
              <Player.MenuRadioItem key={quality.id} value={quality.id}>
                {qualityLabel(quality)}
              </Player.MenuRadioItem>
            ))}
          </Player.MenuRadioGroup>
        )}
        <Player.MenuRadioGroup
          value={String(rate)}
          onValueChange={(value) => void actions.setPlaybackRate(Number(value))}
        >
          {[0.5, 1, 1.5, 2].map((option) => (
            <Player.MenuRadioItem key={option} value={String(option)}>
              {option}×
            </Player.MenuRadioItem>
          ))}
        </Player.MenuRadioGroup>
        <Player.MenuItem onSelect={() => void actions.seekTo(0)}>
          Restart
        </Player.MenuItem>
      </Player.SettingsMenuContent>
    </Player.SettingsMenu>
  );
};

// The caption track list, already wired to the player's own tracks.
export const Captions = () => <Player.CaptionsMenu />;

// The quality ladder, already wired to the player's own qualities. `RateMenu`
// above still composes a quality group by hand alongside playback rate, for
// a consumer who wants both under one trigger; this is the standalone
// primitive for a consumer who wants quality on its own.
export const Quality = () => <Player.QualityMenu />;

// The rate ladder, already wired to the player's own playbackRate. `RateMenu`
// above still composes a rate group by hand alongside quality, for a
// consumer who wants both under one trigger; this is the standalone
// primitive for a consumer who wants playback rate on its own.
export const Rate = () => <Player.PlaybackRateMenu />;

// The chapter list, already wired to the player's own chapters and current
// playback position. Unlike `Quality` and `Rate`, `RateMenu` above has no
// hand-composed chapters group alongside it -- this is the only way this
// package exposes chapters navigation.
export const Chapters = () => <Player.ChaptersMenu />;

// The audio-track list, already wired to the player's own tracks and marking
// whichever one is active. Unlike `Quality` and `Rate`, `RateMenu` above has
// no hand-composed audio-track group alongside it -- this is the only way
// this package exposes audio-track selection.
export const AudioTracks = () => <Player.AudioTrackMenu />;

Hooks

usePlayerState, usePlayerActions, useActiveCues.

Helpers and types

normalizePoster resolves the PosterInput union (string, ResponsivePoster, or a custom element) into the NormalizedPoster shape Poster renders, exported so a consumer building its own poster layer resolves it the same way.

Every component has a matching props type (RootProps, MediaProps, SeekSliderProps, MenuItemProps, …), plus PlayerHandle, PlayerActions, PlayerActivationProps, PosterInput, ResponsivePoster, NormalizedPoster, ErrorDisplayRenderProps, ShortcutAction, ShortcutBindings. The icons below share one type, IconProps, rather than carrying an identical alias each.

PlayerHandle is what a ref on Player.Root receives, and PlayerActions is that same type with the read side (getState, subscribe, on) removed. It is what usePlayerActions returns. Both are derived from the one action list rather than written out twice, so a command reachable through the hook is reachable through the ref and answers the same way.

Icons

import {
  AirPlayIcon,
  CaptionsIcon,
  CheckIcon,
  FullscreenEnterIcon,
  FullscreenExitIcon,
  MutedIcon,
  PauseIcon,
  PipEnterIcon,
  PipExitIcon,
  PlayButton,
  PlayIcon,
  ReplayIcon,
  SeekBackwardIcon,
  SeekForwardIcon,
  SettingsIcon,
  VolumeHighIcon,
  VolumeLowIcon
} from '@playdeck/react';

// Every icon is an optional named export that tree-shakes out when unused, so
// importing one costs you only that one.
export const icons = [
  PlayIcon,
  PauseIcon,
  ReplayIcon,
  VolumeHighIcon,
  VolumeLowIcon,
  MutedIcon,
  SeekForwardIcon,
  SeekBackwardIcon,
  FullscreenEnterIcon,
  FullscreenExitIcon,
  PipEnterIcon,
  PipExitIcon,
  AirPlayIcon,
  CaptionsIcon,
  SettingsIcon,
  CheckIcon
];

// Icons are decorative (`aria-hidden`), sized in `em`, and coloured by
// `currentColor`. A control passing one keeps its own accessible name.
export const CustomPlayButton = () => (
  <PlayButton>
    <PlayIcon />
  </PlayButton>
);

Styling

Every primitive exposes data-playdeck-part (its stable name), data-state (its derived state) and, on provider-bound controls, data-provider. Style and query against those rather than internal class names. Geometry a primitive sets on itself is a default your style prop overrides; state-derived properties are the primitive's own. The full contract is at Contract.

Theming

Both shipped stylesheets are opt-in: the primitive parts import no CSS of their own, and nothing below changes that. Every visual value either stylesheet reads is a CSS custom property, read as var(--name, default) and never required to be set, which is what the Theme guide's starter theme builds on. This is that contract, versioned alongside this package: every token's name, default, and which parts read it, checked against the rules in theme.css, docked.css and every primitive in packages/react/src by test/tokens.contract.test.ts on every run.

Two things that check does not cover. The scan finds a token only where a rule reads it with var(--name, ...); a token a rule only declares with a literal (the way theme.css's phone-width block sets --playdeck-control-size and its siblings) is invisible to it, so a future phone-only token could ship with no row here and this test still green. And the table's Role column is prose the test never reads at all: unlike Default and Parts, it can go stale with nothing to catch it.

--playdeck-media-aspect-ratio is deliberately not in the table below. It runs the opposite direction to everything here (Playdeck writes it, a consumer reads it), so it is an output, not a token, and is documented with the library's other outputs on the Contract page.

The default below is theme.css's own. docked.css reads the same token names, but its surface is a light card rather than a dark scrim, so most of its colours carry a second default of their own: see packages/react/docked.css's header comment for docked's, including its prefers-color-scheme: dark pair. One token, --playdeck-color-hairline, exists only there: theme.css neither reads it nor needs it, since its scrim already supplies the edges a flat surface has to draw itself.

A * under Parts means every part: --playdeck-color-focus outlines whichever part currently has focus, and --playdeck-control-icon-size sizes the svg inside whichever part renders one.

| Group | Token | Role | Default | Parts | | ---------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Color | --playdeck-color-surface | Solid fill behind an overlay or menu | rgb(0 0 0 / 0.72) | activation, controls, error, settings-menu, thumbnail, viewport | | Color | --playdeck-color-on-surface | Ink colour over a surface | #fff | activation, airplay-button, captions-button, controls, error, error-retry, fullscreen-button, live, loading-indicator, mute-button, pip-button, play-button, settings-menu, settings-menu-trigger, viewport | | Color | --playdeck-color-accent | Brand/accent fill: a slider's played span, progress fill and thumb | #3ea6ff | seek-progress, seek-slider-input, volume-slider | | Color | --playdeck-color-accent-tint | The lighter end of the seek fill's gradient | #9dd0ff | seek-progress | | Color | --playdeck-color-track | A slider's unfilled track | rgb(255 255 255 / 0.36) | seek-buffered, seek-slider-input, volume-slider | | Color | --playdeck-color-buffered | A slider's loaded-range indicator | rgb(255 255 255 / 0.7) | seek-buffered-range | | Color | --playdeck-color-thumb-ring | The ring drawn around a slider's thumb | #000 | seek-slider-input, volume-slider | | Color | --playdeck-color-focus | The :focus-visible outline | #fff | * | | Color | --playdeck-color-backdrop | The viewport's own background, behind the media | #000 | media, viewport | | Color | --playdeck-color-duration | The dimmed ink of the duration Time | rgb(255 255 255 / 0.64) | time | | Color | --playdeck-color-hairline | docked.css's own 1px rule between the bar and the picture, and its menus' borders | #d9d9d6 (docked.css only, theme.css never reads it) | activation, controls, settings-menu, thumbnail | | Typography | --playdeck-font-family | The font stack | system-ui, -apple-system, 'Segoe UI', roboto, helvetica, arial, sans-serif | viewport | | Typography | --playdeck-font-size | Body text size | 0.875rem | error-message, menu-item, menu-radio-item | | Typography | --playdeck-font-size-small | Small text size, used by the time readout | 0.8125rem | time | | Typography | --playdeck-line-height | Line height for wrapped text | 1.4 | caption-cue, error-message | | Spacing | --playdeck-space-1 | The smallest spacing step | 0.25rem | controls, error-retry, settings-menu, time | | Spacing | --playdeck-space-2 | The base spacing step | 0.5rem | controls, error, menu-item, menu-radio-item, settings-menu, time | | Spacing | --playdeck-space-3 | The larger spacing step | 0.75rem | activation, error, error-retry | | Shape | --playdeck-radius | Corner radius for a control-sized box. theme.css itself is inconsistent here: the button-shaped controls among the parts listed read it with a 0.625rem fallback rather than the 0.375rem every other listed part reads (a pre-existing mismatch between those rules, not a second token) | 0.375rem (0.625rem for airplay-button, captions-button, fullscreen-button, live, mute-button, pip-button, play-button, settings-menu-trigger) | airplay-button, captions-button, error-retry, fullscreen-button, live, menu-item, menu-radio-item, mute-button, pip-button, play-button, settings-menu-trigger | | Shape | --playdeck-radius-large | Corner radius for a card or menu | 0.5rem | error, settings-menu, thumbnail | | Controls | --playdeck-control-size | A button-shaped control's own square size | 2.75rem