⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

BD Video

Video player with custom controls

Website / BD Video
Download .md file
Open .md in new tab
On this page
  • Anatomy
  • Configuration
  • Basic usage
  • Timed cue cards
  • JavaScript
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product

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

Captions are missing — top roadmap item

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 live aria-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-visible outline 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-label in the same step. Hover tooltips duplicate those labels and carry no unique information; the visible 1:23 / 5:30 readout is aria-hidden, because the scrubber's aria-valuetext already 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 its aria-label is written once per cue rather than on every loop.
  • Reduced motion is honoured: autoplay is 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 / ended listeners, 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 rejected play(), 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-mute or data-bd-fullscreen that 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-tooltip tooltips are hover-only (1.4.13 — mitigated by their labels duplicating the accessible name).

Author requirements:

  • Must label the player: role="region" and an aria-label on the container. The script never reads them — nothing generated depends on it, so the obligation is entirely the author's.
  • Must give the <video> an aria-label naming the content, and a poster so the frame is never blank before playback.
  • Must keep data-bd-mute on 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-prompt with autoplay muted — it is the affordance that makes a silent autoplay honest
  • Add data-bd-pause-offscreen to any autoplaying player on a long page, so scrolled-past video stops costing battery
  • Call cleanupBdVideo() before a router swap and initBdVideo(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-offscreen player 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>
On this page
  • Anatomy
  • Configuration
  • Basic usage
  • Timed cue cards
  • JavaScript
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product
Previous BD Cursor
Next Asset Card

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default