Icons and layout
The reference example in stories/reference/ composes @playdeck/react and
@playdeck/core's public exports into one player. These are the choices it
makes about icons and layout.
Icons are your job, not the library's
Every button here receives an icon child from @playdeck/react's icon exports.
The primitives keep their text fallbacks (children ?? (isPlaying ? 'Pause' : 'Play')) and are unchanged by this example. Defaulting to icons would make
every icon a static dependency of every button and undo the opt-in
tree-shakeability the icon exports exist for.
Accessible names are unaffected either way: they come from each primitive's own
aria-label, never from the child. Supplying icon children is composition
from public parts, which is the thing being proved.
Layout is your job too
The layout CSS lives in this example, not in @playdeck/react/theme.css. The
library stays unopinionated about layout; this example documents one way to lay
a control row out — two rows, a wrapping button row, and the volume slider
dropped below a 420px breakpoint so the composition stays usable at 320px.
That breakpoint is a container query, not a media query: it asks how wide the player is, so an embed in a narrow column gets the same treatment as a narrow phone. Everything responsive here works that way. Below 420px the volume slider drops, PiP and AirPlay fold into the settings menu so the button row fits one line, and the control row leaves the overlay to take its own space in flow beneath the media.
In flow, the box gives up aspect-ratio and keeps a min-height of
56.25cqw — 9/16 of the container. Player.Media takes part in flow, so the
box becomes media plus row and grows when the row needs more room than the
ratio allows, which is what happens at 200% text; without the floor, the states
that hide the control row entirely (before activation, and while an error
surface owns the viewport) would have no in-flow content left and collapse to
nothing, taking the full-bleed overlay with them.
An element is never matched by its own container query, which is why
container-type sits on a wrapper around the player rather than on the player
itself — and why 56.25cqw has an ancestor container to resolve against.
@container is Chrome 105, Firefox 110 and Safari 16 — newer than the floor the
packages declare (Chrome 99, Firefox 97, Safari 15.4). That does not move the
packages' floor: this is a composition in this workbench, never published, and
the primitives ship no CSS at all.
Where a primitive sets geometry on itself, your style prop overrides it —
see the Contract guide's style section for the one carve-out.
The example itself imports no stylesheet beyond its own layout CSS — never
@playdeck/react/theme.css, which is the headless point it is making. The
toolbar's Theme toggle still applies to it like every other story: the
mounting lives in .storybook/theme.tsx, outside the stories/reference/**
import rule, so Reference/Player is themable without the example reaching
for the theme itself.
Folding controls into the menu
Below the breakpoint, PiP and AirPlay leave the button row and reappear as
entries in the settings menu. None of that comes from the library. @playdeck/react
ships no folding, no overflow behaviour and no notion of control priority —
where a control sits, whether it exists at all, and what it contains are your
decisions, taken in your own layout. What follows is a composition pattern this
example authors on top of the primitives, not a feature you can switch on. 420px
is a number this example picked; the row wraps, so it never overflows sideways
and the cost lands vertically instead. At a 320px container the row has 312px of
content to fill with 44px targets, so six fit on a line — and the eight present
wrap two onto a second, taking the button row from 44px to 92px. Shedding two
keeps it to one line. It is not a library constant, and yours will be different.
The mechanism is two forms of the same action, both always rendered, with a pair
of container queries hiding whichever form does not apply at the current width:
below the breakpoint the buttons are hidden and the menu entries show, above it
the menu entries are hidden and the buttons show. Rendering both and hiding one
is deliberate rather than lazy. display: none takes the inactive copy out of
the accessibility tree as well as out of the layout, so no width ever offers the
same action twice — a screen reader finds exactly one PiP control at 320px and
exactly one at 768px. That is what makes duplicating the action correct.
Hiding rather than unmounting is also what turned up a bug in SettingsMenu's
roving focus, now fixed: a display: none entry stayed in the focus ring.
The two queries are off by one on purpose, and the reason is that both of them
hide — there is no show/hide pair here, only two rules that each take a copy
away. max-width is inclusive, so the rule that hides the buttons at
max-width: 420px has to be paired with one that hides the menu entries from
421px up. Write the same number in both and 420px matches both rules, so both
copies are hidden and the action vanishes at exactly that width; leave a gap and
the width in between matches neither rule, so both copies show and the duplicate
is back.
A selector that hides a folded menu entry must match or exceed the specificity
of the menu-item styling it overrides. Get that wrong and the symptom is a
duplicate that never goes away: here a bare single-class selector at (0,1,0)
lost to the menu item's own display: flex at (0,2,0), and a PiP entry sat
visible beside the PiP button at 768px — measured, not theorised. Writing the
hide as a descendant selector, so the two specificities match, is the fix; the
comment above the min-width block in reference-player.tsx records it.
Folded menu entries have to repeat the capability gating their button
counterparts get for free. The button primitives read the capability record
themselves and render nothing when the provider does not offer the action —
the reference example's RealSources story shows what that looks like as you
switch sources. The menu-item primitive gates nothing, so the folded entries
read the same capability statuses their buttons read, and the menu opens even
when those entries are the only thing in it — otherwise the functionality it
absorbed disappears at exactly the width that needs it. Copy only the
buttons' half of this pattern and you ship menu entries for actions the
provider cannot perform.
The folded entries carry visible text alongside their icon; the buttons do not
need it. A menu item's accessible name comes from its content and no primitive
supplies one there, whereas the button primitives carry their own aria-label.
An icon-only menu entry would simply be an unnamed one.
Not every control that leaves at a narrow width can fold, and that is a limit of
the pattern rather than a gap in this example. Folding needs a menu-item form of
the action, and the settings menu's content root is a role="menu", which admits
only menu items, radio items, groups and separators. A range input is not a
permitted child of one — measured, as a critical aria-required-children
violation. So the volume slider drops below the breakpoint instead: mute and
unmute survive it, and #196 records the decision that the drop stays.