BD Video is the full player: a large centre play/pause button, a seek scrubber with time display, mute and fullscreen buttons, keyboard shortcuts, and timed link cards that surface over the frame. Author two elements — a container and a <video> inside it — then switch features on with data-bd-* attributes on the container. Everything else (buttons, scrubber, cue slot, icon sprite) is built by assets/js/bd-video.js at init, so the markup stays two elements no matter how many features run. Companion styles live in bd-video.css.
For a decorative loop with no controls, use a plain <video autoplay muted loop playsinline> instead — this component is for video the visitor is meant to operate.
Anatomy
Every feature is off until its attribute is present. All attributes are booleans on the container.
| Axis | Mechanism | Example |
|---|---|---|
| Seek bar + time display | data-bd-scrubber |
data-bd-scrubber |
| Mute / unmute button | data-bd-mute |
data-bd-mute |
| Fullscreen button | data-bd-fullscreen |
data-bd-fullscreen |
| Large unmute prompt | data-bd-unmute-prompt |
data-bd-unmute-prompt |
| Timed cue cards | data-bd-cues |
data-bd-cues |
| Pause when scrolled out of view | data-bd-pause-offscreen |
data-bd-pause-offscreen |
Always on, no attribute needed: the centre play/pause button, click-the-video-to-toggle, controls that auto-hide after 3s idle, keyboard shortcuts, state-aware aria-labels, and the reduced-motion carve-out.
The component consumes design-system tokens only through namespaced --bd-video-* tokens with literal fallbacks, so it works on pages without design-system.css.
Configuration
Define window.bdVideoConfig before the script loads. Every key is optional.
| Key | Default | Meaning |
|---|---|---|
cueLabel |
"View case study" |
Float-cursor label shown over a timed cue card. Applies only when the player rides the custom cursor (data-cursor-badge on the container) |
Name the destination in cueLabel — the default suits a studio showreel linking to case studies; a product tour linking to docs would set cueLabel: "Read the guide".
Basic usage
<div class="bd-video" role="region" aria-label="Video player"
data-bd-scrubber data-bd-mute data-bd-fullscreen>
<video class="bd-video-player"
src="/assets/video/showreel.mp4"
poster="/assets/images/showreel-poster.jpg"
aria-label="Studio showreel"
playsinline
preload="metadata"></video>
</div>
A muted autoplay preview adds the unmute prompt. In preview state the player runs silent and the centre button invites a start; the first play/unmute restarts from zero with sound.
<div class="bd-video" role="region" aria-label="Video player"
data-bd-scrubber data-bd-mute data-bd-fullscreen data-bd-unmute-prompt data-bd-pause-offscreen>
<video class="bd-video-player"
src="/assets/video/showreel.mp4"
poster="/assets/images/showreel-poster.jpg"
aria-label="Studio showreel"
autoplay muted loop playsinline preload="auto"></video>
</div>
Timed cue cards
data-bd-cues reads an inline JSON block inside the container and shows one link card at a time over the frame. start, end, title, href and image are required; excerpt is optional. Entries are validated (invalid ones are dropped with a console warning), sorted by start, and their thumbnails preloaded.
<div class="bd-video" role="region" aria-label="Video player" data-bd-scrubber data-bd-cues>
<video class="bd-video-player"
src="/assets/video/showreel.mp4"
poster="/assets/images/showreel-poster.jpg"
aria-label="Studio showreel"
playsinline></video>
<script type="application/json" data-bd-cues-data>
[
{
"start": 0.3,
"end": 10,
"title": "Verizon HBCU",
"excerpt": "Sold out Gen-Z show in 3 cities.",
"href": "/work/verizon-hbcu.html",
"image": "/assets/images/work/verizon/sidebar_verizon.jpg"
}
]
</script>
</div>
Clicking a card pauses the video and navigates — exiting fullscreen first when the player is fullscreen. Cues are suppressed during preview, during scrubbing, and for 800ms after a preview exit. Hovering a card holds it open past its end time (released on mouse leave, with a 3s fallback for touch).
JavaScript
Include assets/js/bd-video.js once per page. It self-initialises on DOMContentLoaded and exits early when the page has no .
| Function | Purpose |
|---|---|
window.initBdVideo(scope) |
Initialise every player inside scope (default document). Idempotent per player — an already-initialised container carries data-bd-init and is skipped |
window.cleanupBdVideo() |
Disconnect the viewport observers and remove the document-level listeners before a router swaps page content. Idempotent |
A client-side router must call both: cleanupBdVideo() before the swap, initBdVideo(container) on the entering container.
Events
| Signal | Direction | Purpose |
|---|---|---|
bd:after-nav (legacy alias studio:after-nav) |
listened | Start a deferred autoplay once the page transition has finished. Both names are bound and the first to arrive wins, so a router can dispatch either |
bd:intro-complete |
listened | Start autoplay in sync with an intro curtain reveal, while <body> carries is-intro-loading |
bd-video:statechange |
dispatched | Playback state changed. detail.state is preview, playing or paused |
bd-video:cue-enter / bd-video:cue-exit |
dispatched | A cue card appeared or left. detail.cue carries start, end, title, href, index; exit adds detail.reason (time or ended) |
Both deferred-autoplay paths have a 4s watchdog, so a router that never dispatches its event still plays.
The same state is mirrored on the container as data-bd-state="preview | playing | paused" — the attribute is the CSS and adapter hook; the event is the notification.
Pairing with BD Cursor
When the container carries data-cursor-badge, the player drives the custom cursor: the riding glyph tracks play/pause and mute state, the scrubber is carved out with data-cursor-native so seeking keeps the OS pointer, and a cue card swaps to the cueLabel float label. Each swap dispatches bd-cursor:refresh so the change lands under a stationary pointer.
The icon sprite bd-cursor loads must contain play, pause, sound-off, sound-on, full-screen and arrow-top-right. A missing symbol renders a cursorless region with no error.
Without bd-cursor the pairing is inert: no data-cursor-* attributes means no cursor work, and bd-video.css's single rule (isolated in its own section at the end of the file) never matches. The player's own icons come from a scoped #bd-video-* sprite it injects itself — it never depends on a host page's sprite.
Accessibility
The player has no caption or subtitle support: no <track> handling, no CC toggle. Any video carrying speech fails WCAG 1.2.2 Captions (Prerecorded), Level A. This is the component's highest-priority roadmap item. Audio description and a text media alternative (1.2.3, Level A; 1.2.5, Level AA) are outstanding with it. Until captions ship, either publish a caption-bearing alternative alongside the player, or use bd-video only for video with no spoken content — and note that silent video carrying information still needs a text alternative under 1.2.1.
What is accessible today:
- Keyboard shortcuts, active while focus is inside the player: Space or K toggles play, M toggles mute, F toggles fullscreen, ← / → seek 5 seconds. Letter keys match lowercase only, and M / F need their feature attribute present.
- The scrubber is a real slider:
role="slider",tabindex="0",aria-label="Seek", and livearia-valuemin/aria-valuemax/aria-valuenow/aria-valuetext(spoken as "1 minute 4 seconds of 3 minutes 20 seconds"). Focused, it takes ← → ↑ ↓ for 5-second steps, PageUp / PageDown for 10% of duration, and Home / End for start and end. - Every generated control has a focus ring: a 2px
:focus-visibleoutline with offset on the centre button, unmute prompt, mute button, fullscreen button, scrubber and cue card. - Icon and accessible name never diverge: every code path that swaps the centre, mute or fullscreen icon rewrites that button's
aria-labelin the same step. Hover tooltips duplicate those labels and carry no unique information; the visible1:23 / 5:30readout isaria-hidden, because the scrubber'saria-valuetextalready carries it. - Auto-hide respects focus: when the 3s idle timer fires with focus inside the player, the controls stay up.
- Cue cards have a live region: the cue region is
aria-live="polite", and itsaria-labelis written once per cue rather than on every loop. - Reduced motion is honoured:
autoplayis stripped, the deferred-autoplay marker is dropped, the video is paused, and control transitions are switched off.
Known gaps beyond captions — treat these as constraints when placing a player, and as the roadmap after captions:
- Controls hidden by the idle timer stay in the tab order at
opacity: 0, and focus arriving afterwards does not bring them back (2.4.7). The mute button suppressed by the unmute prompt () is invisible and focusable the same way. - There are no
play/pause/endedlisteners, so state changed outside the component's own handlers leaves the centre button's label stale (4.1.2): a video that ends, the offscreen soft-pause, a rejectedplay(), and the browser's own context-menu or picture-in-picture controls. - Space is handled at the player root, so it toggles playback rather than activating the focused button (2.1.1), and the arrow shortcuts have no modifier guard, so Alt + ← seeks instead of going back (2.1.4).
- Below 768px a preview player hides the centre button; with no
data-bd-muteordata-bd-fullscreenthat configuration leaves no keyboard-operable play control at all (2.1.1). Ship at least one control button on mobile preview players. - Fullscreen uses the unprefixed API only, so the button and F are a silent no-op on iOS Safari, and fullscreen neither traps focus nor makes the rest of the page
inert(2.4.3). - The focus ring is white on whatever video frame sits behind it, so 3:1 against adjacent colour is not guaranteed (1.4.11), and
data-tooltiptooltips are hover-only (1.4.13 — mitigated by their labels duplicating the accessible name).
Author requirements:
- Must label the player:
role="region"and anaria-labelon the container. The script never reads them — nothing generated depends on it, so the obligation is entirely the author's. - Must give the
<video>anaria-labelnaming the content, and aposterso the frame is never blank before playback. - Must keep
data-bd-muteon any player that carries audio: without it there is no in-player way to silence sound, which fails 1.4.2 Audio Control, Level A for anything that autoplays past 3 seconds. - Must not rely on the centre button being present on small screens in preview state — the CSS hides it there. Tapping the video or the unmute prompt starts playback, and the button returns once the video is paused; see the keyboard gap above for the configuration to avoid.
Usage rules
Do
- Author the two elements and let the script build the rest — the controls are generated, not authored
- Turn on only the features the placement needs; every attribute adds a control the visitor must parse
- Pair
data-bd-unmute-promptwithautoplay muted— it is the affordance that makes a silent autoplay honest - Add
data-bd-pause-offscreento any autoplaying player on a long page, so scrolled-past video stops costing battery - Call
cleanupBdVideo()before a router swap andinitBdVideo(container)after it
Don't
- Don't use bd-video for decorative background loops — a plain
<video>with no controls is lighter and needs no JS - Don't ship a player for video with speech until captions land (see above)
- Don't hand-author the centre button, controls bar or scrubber — the script owns that DOM and removes stale copies
- Don't re-theme by overriding
.bd-video-*rules; override the--bd-video-*tokens instead - Don't put a
data-bd-pause-offscreenplayer in persistent chrome outside the router's swapped container — its observer is torn down on nav and only re-created on the entering container
CSS reference
This section documents how the component is built. For usage, see the sections above.
Styles ship as their own file, dist/css/bd-video.css, linked after design-system.css.
Tokens
Every token is scoped under and resolves through a design-system variable with a literal fallback, so the component themes inside the system and still renders outside it.
| Token | Default | What it controls |
|---|---|---|
var(--text-accent, #3485cd) |
Scrubber fill | |
var(--yellow-lighter, #fff8e0) |
Cue card background | |
var(--yellow-light, #ffe89c) |
Cue card background on hover | |
var(--neutral-900, #1f1f1f) |
Cue card title | |
var(--neutral-700, #565656) |
Cue excerpt and mobile arrow | |
var(--duration-2xs, 100ms) |
Hover / background changes, scrubber height | |
var(--duration-s, 400ms) |
Button and cue card fades | |
var(--duration-2xl, 1200ms) |
Controls-bar auto-hide fade | |
var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1)) |
Every transition | |
| … | var(--space-*, …) |
Control padding, gaps, offsets, scrubber height |
var(--space-s, 0.75rem) |
Outer inset for the scrubber and cue region | |
var(--font-primary, system-ui, …) |
All player text | |
/ -xs / -m |
var(--font-*, …) |
Cue excerpt / time + tooltips / unmute prompt |
var(--radius-s, 6px) |
Tooltips and cue card |
Cue colours deliberately never inherit semantic tokens like --text-primary: those flip in dark mode, the video behind them does not.
Three tokens are declared for completeness but currently unreferenced: --bd-video-duration-mobile-scrubber, --bd-video-space-2xl, --bd-video-radius-m.
Selectors
| Selector | Purpose |
|---|---|
| Container, token scope, positioning context | |
The <video> element |
|
| Large centred play/pause button | |
| Large unmute button (muted autoplay only) | |
/ -fill |
Seek bar and its filled portion |
| Time readout following the pointer along the scrubber | |
/ -right |
Control bar and its right-hand button group |
Visible 1:23 / 5:30 readout (aria-hidden) |
|
| Pill control button (mute, fullscreen) | |
Persistent aria-live wrapper for cue cards |
|
| The cue card — one reused link element | |
/ -text / -title / -excerpt |
Cue card parts |
.bd-video .svg-icn |
Local icon box, scoped so the component needs no global rule |
State classes
| Class | Meaning |
|---|---|
| Silent preview (muted autoplay); play button and unmute prompt stay visible | |
| Paused — keeps the centre button visible through auto-hide | |
| Controls faded out after 3s idle | |
| Visitor has unmuted; hides the unmute prompt permanently | |
| A cue card is showing | |
| On the scrubber while dragging | |
| On the mute button while the unmute prompt owns that job |
Key rules
Buttons cancel the global :active press transform: the centred controls use transform for positioning, so replacing it would move the hit zone out from under the pointer mid-click.
The scrubber gets a 30px ::before hit area extending above the 8px track, so seeking is reachable on touch.
Under 768px the centre button is hidden in preview state only (tapping the video or the unmute prompt starts playback), the unmute prompt moves to the top-right, buttons grow to 48px, and cue cards drop the thumbnail and excerpt for a text-plus-arrow row.
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship as dist/css/bd-video.css — copy it into the product's served assets and link it after design-system.css. Its behaviour ships as dist/js/bd-video.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/bd-video.js" defer></script>