Data-attribute contract
Every primitive exposes a stable styling/testing contract via data attributes.
data-playdeck-part— the stable name of a part: one named element in the rendered DOM (play-button,mute-button,volume-slider,seek-slider/seek-slider-input/seek-buffered/seek-buffered-range/seek-progress/seek-buffered-description/thumbnail,fullscreen-button,pip-button,airplay-button,remote-playback-button,time,controls,poster,poster-image,loading-indicator,activation,viewport,media,error/error-message/error-retry,notice/notice-message,captions/captions-button/captions-announcer/caption-cue/caption-line,settings-menu-root/settings-menu-trigger/settings-menu,menu-item/menu-radio-group/menu-radio-item/menu-radio-indicator,gestures,live). A part name is not a primitive's name, and several parts can come from one primitive.ErrorDisplayrenderserror, and by defaulterror-messageand a recoverable error'serror-retryinside it. The slashes above only group related names —captions/captions-button/captions-announcerspans two primitives.noticehas no default appearance, deliberately. A Notice is a non-fatalconfigurationerror reporting a consumer-supplied value that was rejected — an option a provider refused, or a URL prop the library's own allowlist refused with no provider involved — while the fall-back it degraded to stands unchanged; nothing stopped working. SoErrorDisplayrenders it without geometry, without stacking and withoutrole="alert", and a player that is still playing is never covered by one (#319). It carries the error category ondata-statelikeerrordoes, and it is in the DOM for a consumer to place and for a monitoring system to read. A notice the library's own allowlist published leaves the DOM again once nothing holds the rejected value — the prop is fixed, or the component holding it goes away — because that kind of notice reports a rejection that stands, not one that once happened (#330). Notices from elsewhere are withdrawn on their own terms: a provider's goes with its provider, and a configuration conflict's goes when the configuration changes. There is one error slot and no event carries the notice that loses it, so an attach rejecting two values puts only one of them in the DOM: the one of higher severity — a control that protects the viewer fired, rather than a cosmetic option ignored — whatever order the two were reported in (#368). A failure — including a non-fatalprovider,policyorunsupportedone — still renderserrorwith its overlay and its retry.timeshows nothing where the source has no duration.data-stateon thetimepart istimedoruntimed, and a source is untimed wherever its duration is not a finite number — a live stream, or one whose duration has not arrived yet.type="duration"andtype="remaining"render empty text there rather than0:00, which reads as a zero-length video (#248). Nothing is substituted for it: aLIVEbadge, an em dash or an elapsed-time fallback is a consumer's to compose off[data-state='untimed']in their own layout, or to pass aschildren.type="current"changes on a live source too, but not by emptying: it reportslive.offsetFromEdgein place of elapsed time — the wordLIVEonce playback is at the edge, and a negative offset while behind it — so acurrentinstance is always a<time>, never the<span>below, and it still carriesdata-state="untimed"alongside, because that attribute names the source rather than what the instance is showing. The full account, includingliveLabel, is in the Live guide. The element itself is a<span>in that state rather than a<time>— there is no time to mark up, and an emptied<time>would carry neither adatetimenor a parseable time. The swap is keyed on the source being untimed, not on the text coming out empty:<Player.Time type="duration">LIVE</Player.Time>on a live source is a<span>that displays something. Every attribute the part carries survives it —data-playdeck-part,data-state,data-time-typeanddata-provider— so select on[data-playdeck-part='time']rather than on the element type.datetimeis the library's in both states: it is absent on the<span>, and adateTimeprop is dropped there rather than passed through. Becausedurationisnulluntil metadata arrives on nearly every source, adurationorremaininginstance normally starts as a<span>and is replaced by a<time>when metadata lands, rather than re-rendering in place — so a CSS transition, focus, or aMutationObservera consumer has put on that node resets at that moment.data-state— the part's derived state (e.g.paused/playing,muted/unmuted,idle/ready,loading/loaded/error,global/scoped,timed/untimedontime,at-edge/behind-edgeonlive, the error category onerror, or the activation state). Absent where the part has no observable state:airplay-buttonopens a system picker, and Playdeck does not currently surface an active-route flag, so it exposes nodata-staterather than a value that never changes. That is today's behaviour rather than a permanent guarantee — WebKit can report an active wireless route, and Playdeck has deferred plumbing it.remote-playback-buttonis the same shape: which device the viewer picked is never exposed, so it carries nodata-stateeither, even thoughPlayerState.remotePlaybackdoes publish a connection state elsewhere for a consumer who wants it.data-provider— carries the active provider name, or is absent when no provider is attached. By design it appears only on the provider-bound control parts (play-button,mute-button,volume-slider,seek-slider,time,fullscreen-button,pip-button,airplay-button,remote-playback-button,controls,error,captions-button), not on the structural/activation parts (viewport,poster,poster-image,media,activation,loading-indicator), which don't read provider state and so avoid re-rendering when it changes.data-time-type— which of the three times the part reads,current,durationorremaining, mirroringTime'stypeprop. Only ontime, where several instances usually sit side by side and are otherwise indistinguishable to a selector. It survives the<span>/<time>swap above, so[data-playdeck-part='time'][data-time-type='duration']addresses the same instance in both states.data-buffering—"true"while a stall has been admitted,"false"otherwise. Only onseek-slider. Debounced: a stall must persist 500ms before it is admitted, and once admitted it is held 500ms, so a short rebuffer never twitches the slider.data-stateon the same element is a separate axis (ready/idlemeans "is there a seek window") and does not move during a stall. The undebounced signal stays available to consumers asstate.buffering.data-idle—"true"once playback has run 2500ms with no pointer or keyboard input,"false"otherwise. Only onviewport, written there byViewportitself. The timer runs only while playback isplaying: it is armed when playback starts, and pausing clears it and writes"false", so a paused player never goes idle and input during a pause starts nothing. Apointermove,pointerdown,touchstart,keydownorfocusinresets it — all five bound to the viewport element rather than todocument, so one player's pointer traffic never keeps a second player on the same page awake. Absent until the viewport mounts, and absence means awake. So key the idle look on[data-idle='true']rather than keying the visible look on[data-idle='false']: the latter would hide whatever it styles across that first paint, and it would also miss a composition that rendersControlsoutsideViewport, where nothing above the bar carries the attribute at all. It reports input, not attention. The timer never inspects focus or whether a menu is open, sodata-idlecan read"true"while a focused control keeps the controls plainly on screen — pair it with:focus-withinif that is the behaviour you want.@playdeck/react/theme.cssdoes exactly that to fade its overlaid control bar out;@playdeck/react/docked.cssnever reads the attribute, because nothing it draws sits over the picture. Written straight to the DOM rather than through player state, like the aspect-ratio property below, so going idle and coming back re-renders nothing.thumbnailisSeekSlider's opt-in seek-preview crop, present only while itsthumbnailsprop resolves to a permitted URL.data-stateishiddenorvisible, mirroringposter's own vocabulary for the same reason: the part stays mounted through every hover start and stop rather than mounting and unmounting with each one, so a theme can transition between the twodata-states instead of a hard pop. It readsvisibleonly once there is an actual image behind it -- a resolved cue at the previewed time, and that cue's own image URL permitted -- andhiddenfor every other reason there is nothing to show: no hover or focus is active, the WebVTT file has not loaded yet, no cue covers that time, or the cue's image was refused. The previewed time is the pointer's position while a pointer is over the slider, else the input's own current value while it holds keyboard focus, else there is no preview; a pointer active at the same time as focus wins.aria-hidden="true", likeseek-bufferedabove it -- decorative geometry, never a live region.livereflectsPlayerState.live(PlayerLiveState).nullthere means the stream is not live or its liveness is not yet known, andLiveIndicatorrenders nothing for it -- there is no thirddata-statefor "not live", becausePlayerLiveStateis non-null only whenisLiveistrue;isLive: falseis not a valuederiveLiveStateproduces. Sodata-statecarries only the two reachable values:at-edgeat the live edge,behind-edgeonce the viewer has fallen behind it. Structurally a<button type="button">, anddisabled-- non-interactive, out of the tab order rather than merelyaria-disabled, which would leave it focusable for a press that does nothing. That is current behaviour rather than a permanent guarantee: if live-edge seeking is ever wired onto this control, it gains anonClickand shedsdisabled.
Style and query against these attributes rather than internal class names.
Styling a part
The attributes above are the selectors, so a headless player needs no class
names to look like something. Four worked examples follow — a button, a slider,
an overlay, and one whose data-state is the whole point. Four, not twenty: the
lesson is the same for the rest.
Each block is generated from a file in examples/, and the story it names
mounts that same file for its Styled story, so what you read here is what
rendered there. Plain CSS, because a data attribute is a plain CSS selector and
every consumer — Tailwind, CSS modules, styled-components, none of the above —
can translate from that.
A button — Player/PlayButton. data-state carries the playback state,
which is paused, playing or ended, and data-autoplay-state reports a
refused autoplay:
/* Styling a part: a button.
`data-playdeck-part` is the hook and `data-state` is the primitive's output, so
the pressed look needs no class name, no wrapper and no JavaScript. */
[data-playdeck-part='play-button'] {
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: #101828;
color: #ffffff;
font: inherit;
cursor: pointer;
}
[data-playdeck-part='play-button']:hover {
background: #1d2939;
}
[data-playdeck-part='play-button']:focus-visible {
outline: 2px solid #7aa7ff;
outline-offset: 2px;
}
/* Playing, so the button now offers "Pause". */
[data-playdeck-part='play-button'][data-state='playing'] {
background: #2e5aac;
}
/* Autoplay was refused by the browser: draw attention to the one control that
can start playback, because nothing else will. */
[data-playdeck-part='play-button'][data-autoplay-state='blocked'] {
outline: 2px solid #f2b53d;
outline-offset: 2px;
}A slider — Player/SeekSlider. One part contains four visible others
(seek-slider-input, seek-buffered, a seek-buffered-range per range, and
seek-progress, the span of the seek window before the current position) plus
seek-buffered-description, the visually hidden text equivalent of the buffered
geometry, and data-buffering is a second axis beside data-state:
/* Styling a part: a slider.
Five parts, no markup of your own: `seek-slider` is the positioned box,
`seek-buffered` the layer under the input, `seek-buffered-range` one span
per buffered range, `seek-progress` the span before the current position, and
`seek-slider-input` the native range input. Reach for the part name rather
than a tag selector — the part is the contract, the element type is the
primitive's business. */
[data-playdeck-part='seek-slider'] {
display: flex;
align-items: center;
}
[data-playdeck-part='seek-slider-input'] {
position: relative;
z-index: 1;
margin: 0;
background: transparent;
accent-color: #7aa7ff;
}
[data-playdeck-part='seek-buffered'] {
position: absolute;
inset-inline: 0;
height: 4px;
border-radius: 2px;
background: #2a3140;
}
/* The ranges are placed by the primitive — `position`, `left` and `width` are
inline and state-derived — so only the paint is left to do here. */
[data-playdeck-part='seek-buffered-range'] {
top: 0;
height: 100%;
border-radius: inherit;
background: #4b566b;
}
/* Placed by the primitive too, and rendered after the ranges, so it paints over
the loaded colour wherever the two overlap. */
[data-playdeck-part='seek-progress'] {
top: 0;
height: 100%;
border-radius: inherit;
background: #7aa7ff;
}
/* No seek window yet — a live edge, or a duration nobody knows. Say so rather
than showing a scrubber that cannot move. */
[data-playdeck-part='seek-slider'][data-state='idle'] {
opacity: 0.5;
}
/* A stall that outlasted the debounce. A separate axis from `data-state`,
which reports whether a seek window exists and does not move during one. */
[data-playdeck-part='seek-slider'][data-buffering='true']
[data-playdeck-part='seek-buffered'] {
background: #6b4a12;
}An overlay — Player/Poster. The primitive owns the structural geometry
and the visibility; the appearance is yours:
/* Styling a part: an overlay.
`Poster` carries its own structural geometry — absolutely positioned, inset
0, z-index 10 — and computes its `visibility` from player state, while
`PosterImage` sets `display`, `width` and `height` inline. None of that can
be beaten from a stylesheet, so restating any of it here would be dead CSS
rather than a conflict (ADR-0001). A stylesheet paints, and that is all. */
[data-playdeck-part='poster'] {
background: #0b0e13;
}
[data-playdeck-part='poster-image'] {
opacity: 0;
transition: opacity 200ms ease;
}
/* The load state is on the element, so the frame fades in once it has actually
decoded rather than flashing in half-painted. An image that fails to load
never reaches `loaded`, so it never fades in: the state machine hides it,
and `[data-state='error']` needs no rule of its own to do that. */
[data-playdeck-part='poster-image'][data-state='loaded'] {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
[data-playdeck-part='poster-image'] {
transition: none;
}
}A data-state worth styling — Player/ActivationButton. Read the states
before you style them: dormant and error are the two a press acts on,
eligible means activation has already been committed, and the overlay
unmounts at ready rather than wearing it:
/* Styling a part whose `data-state` is the whole point.
The overlay carries `dormant`, `eligible`, `loading-provider` and `error`;
it unmounts at `ready`, so no stylesheet ever sees that value. Each of the
four wants a different affordance. The button positions itself (absolutely,
inset 0, z-index 30) and resets the user agent's own button face, so this
only paints.
The fill goes through `--playdeck-activation-fill` rather than through
`background`. Whatever `ActivationButton` writes inline is out of a
stylesheet's reach, and it writes each of those properties as a read of a
token: `background-color` from `--playdeck-activation-fill` and `border`
from `--playdeck-activation-border`, so a bare player never paints the user
agent's own button face over its poster. A `background` or `border`
declaration written here would lose to them, whatever its specificity.
Setting the tokens reaches the same paint instead, and they cascade and vary
by state exactly as a `background` would -- the rules below move the fill per
`data-state` and on hover. Everything else here is ordinary CSS against the
part. */
[data-playdeck-part='activation'] {
display: grid;
place-items: center;
--playdeck-activation-fill: rgb(11 14 19 / 65%);
color: #ffffff;
font: inherit;
cursor: pointer;
}
/* The resting state: a press here starts activation. One of the two states a
press acts on at all — `error` below is the other. */
[data-playdeck-part='activation'][data-state='dormant']:hover {
--playdeck-activation-fill: rgb(11 14 19 / 40%);
}
/* Activation is committed and the provider is on its way. Pressing again does
nothing from here, so stop inviting it. */
[data-playdeck-part='activation'][data-state='eligible'] {
opacity: 0.6;
cursor: default;
}
/* Mid-load, and `aria-disabled`: it must not look pressable. */
[data-playdeck-part='activation'][data-state='loading-provider'] {
cursor: progress;
}
/* The child text reads "Retry" where the error can be retried, and a press does
retry — activation acts on this state as well as on `dormant`. So colour it
as a recovery, and keep the affordance. The exception is an error the state
reports as not recoverable — every configuration error among them — which
reads "Play", is `aria-disabled`, and says so through that rather than
through colour. */
[data-playdeck-part='activation'][data-state='error'] {
--playdeck-activation-fill: rgb(63 18 18 / 85%);
}
[data-playdeck-part='activation'][data-state='error']:hover {
--playdeck-activation-fill: rgb(90 26 26 / 85%);
}None of this is @playdeck/react/theme.css, which is a whole opinion you can take
or leave. These are the smallest rules that make one part look deliberate.
The media's aspect ratio
One more output, and the only one that is not an attribute. When the active
provider can measure the media it is playing, Playdeck writes that media's own
ratio onto the viewport part as a custom property — for a vertical clip,
--playdeck-media-aspect-ratio: 1080 / 1920.
It runs the opposite way to a theme token. A token is a value you set and a
primitive reads; this one the library writes and you read, which makes it a
sibling of data-state rather than of anything in theme.css. Playdeck never
applies it to anything, so opting in is one rule of your own:
/* Reading a value the library writes.
`--playdeck-media-aspect-ratio` is an output, not a token: Playdeck sets it on the
viewport part once a provider has measured its media, and this one rule is
what opts in to it. Nothing in the library reads it back, so without a rule
like this the player is shaped by whatever you already sized it to.
The fallback is not decoration. It is the whole behaviour wherever no ratio
is known — every YouTube source, and any other source until its metadata has
arrived — because the property is absent then rather than zero. Pick a value
you are happy to render, and size the box's width yourself as usual. */
[data-playdeck-part='viewport'] {
aspect-ratio: var(--playdeck-media-aspect-ratio, 16 / 9);
}Real playback/AspectRatio mounts that same file over a portrait source next to
a 16:9 one, so you can see one rule produce two shapes.
Which providers measure, and what from:
| Provider | Reports the ratio |
|---|---|
| native | Yes — the media's own videoWidth/videoHeight, once metadata has loaded and again whenever the frame size changes under it. |
| hls | Yes, on both engines. The picture is drawn into the same <video> the native provider drives, so the measurement is the native one. |
| vimeo | Yes — the size the Vimeo SDK reports, once the embed is ready and again when it changes. |
| youtube | No. The IFrame API exposes no intrinsic size, and what Playdeck mounts for it is not a media element, so there is nothing to measure. |
Playdeck does not guess a ratio it cannot measure, and does not read one out of a
source URL — a /shorts/ path is not a measurement. A consumer who knows they
are rendering a Short can set the property themselves.
Absent, never defaulted. Before metadata arrives, on every YouTube source,
and on audio-only or errored media, the property is simply not there — which is
what lets the var() fallback in your rule apply. It clears the same way:
changing source publishes the new ratio or removes the property, and never
leaves the previous source's shape behind. So the fallback you choose is the
shape a YouTube player will always have.
The value is written straight to the DOM rather than through player state, so a ratio arriving mid-load re-renders nothing.
The style prop and a primitive's own geometry
Some primitives set geometry on themselves — Viewport is position: relative,
the overlays are position: absolute; inset: 0 with a z-index, and so on.
Where that collides with a style prop you pass, the rule is:
- Static geometry is a default. Your
stylewins. Every property a primitive hardcodes for layout is spread before...style, so passingstyle={{ zIndex: 5 }}toErrorDisplaygives you 5, not 40. Layout is the consumer's job, and this is the escape hatch that makes that true. - State-derived properties are the primitive's own. Your
styledoes not win.Postercomputesvisibilityfrom whether the poster has been hidden yet; overriding it would not adjust layout, it would pin a state machine open for every source. The test is mechanical: if the value comes from player state, it is not yours to override. - An explicit prop beats
style.PosterImagetakesobjectFitandobjectPositionprops whose defaults are the--playdeck-poster-fitand--playdeck-poster-positiontheming variables. Precedence runs prop →style→ variable default.
Overriding a primitive's geometry is allowed, not recommended: the overlays'
z-index values are chosen to stack correctly against each other, and
changing one is your responsibility to keep coherent.
Real playback (real providers, real media) is demonstrated in the
Real playback/Providers stories, with the Fixtures/PlayerFixture stories
driven by the e2e suite. Those stories hit the network and are excluded from
the deterministic story test run (tagged !test).
Element types are fixed
Each primitive renders fixed element types, and that is a decision rather than
an omission: PlayButton renders a <button>, Viewport a <div>, Time a
<time> — or a <span> where it has no time to mark up, as above. No prop
swaps those elements for others.
Where a primitive hands over what goes inside one, it does so through an
explicit, typed prop rather than a general substitution mechanism:
ErrorDisplay takes render-prop children called with { error, retry }, and
Captions takes renderCue. Both replace inner rendering while keeping their
own element and its part attribute.
What you extend otherwise:
- Children. The button primitives above and
Timerender children in place of their default content.<Player.PlayButton><PlayIcon /></Player.PlayButton>swaps the label and keeps the button's accessible name.SeekSliderrenders children inside itsseek-sliderelement, after the input;VolumeSlider, a bare<input type="range">, takes none. classNameandstyle, plus a primitive's own props where it has them —SeekSlider'sinputProps,PosterImage'sobjectFitandobjectPosition.stylefollows the precedence rules above.
The part attributes work the other way round: data-playdeck-part and
data-state are spread after your props, so they are the primitive's output —
yours to read from CSS and tests, not to set.
The reason is accessibility. The button primitives above set type="button",
an aria-label, and aria-pressed where there is a pressed state to report,
on an element whose shape they know. None of that survives being moved onto an
element the primitive has never seen.
WCAG 2.2 AA is the standard releases are gated on here rather than an
aspiration, and it is worth being exact about which half of that gate has been
cleared. Automated: yes, and broadly. Axe runs its default rule set — not a
narrower WCAG-tag subset — over nine states of the composed example in Chromium,
Firefox and WebKit, and over every primitive story in Chromium. Alongside it:
three reflow cases, tab order and menu focus return, focus-not-obscured
hit-tests, shortcut keys checked against the real media element rather than the
DOM, and a live-region policy asserting that state transitions announce and time
updates never do. Axe's needs-review bucket is asserted by equality too, so a
new undiagnosed finding fails the run rather than passing quietly — two states
carry a diagnosed entry, and the rest assert it empty. Where you use the
optional stylesheet, its forced-colors handling has its own tests, including
ones that measure rendered pixels rather than computed CSS; the headless
primitives ship no forced-colors handling to test. The reference example in
stories/reference/ sets all of that out in full.
Human: not yet. Conformance to 2.2 AA as a whole is not something a scanner can settle — whether focus is genuinely visible against the backdrop, whether captions stay legible over a bright frame, whether a screen reader's account of the player makes sense. That review is owner work, it is still open, and until it closes nothing here should be read as saying a released version has passed a full 2.2 AA audit. What is published is the automated evidence above, which is specific and checkable, and an honest statement of where it stops.