Skip to content

Accessibility

Every player is keyboard-operable and screen-reader-aware out of the box. The waveform is exposed as a native role="slider", the play button and cue markers carry accessible names, the optional playback-speed menu announces its open state (aria-expanded) and closes on Escape, focus is visible, animation honours prefers-reduced-motion, and self-mode players register system Media Session controls. Nothing on this page requires configuration — it is on by default.

A player exposes two distinct keyboard targets, each with its own keys:

Element CSS class In tab order? Handles
The seek slider .waveform-container Yes (tabindex="0") Arrow / Page / Home / End seeking — both audio modes
The player root .waveform-player Only after a click (tabindex flips -10) Space, digits 09, volume, mute — self mode

The slider is reachable with Tab. Clicking an interactive control keeps focus on it — the play button and cue markers activate without moving focus, and clicking the waveform focuses the slider. Clicking a non-interactive area (the title, the info row, surrounding padding) focuses the root, which also pulls focus off sibling players so transport keys only ever drive one instance.

Focus the waveform with Tab, then:

Key Action
/ Seek forward 5 s
/ Seek back 5 s
Page Up Seek forward 10 s
Page Down Seek back 10 s
Home Jump to start (0 s)
End Jump to the end

These work in both audio modes. In self mode they call seekTo() directly; in external mode they dispatch a cancelable waveformplayer:request-seek event (the controller may preventDefault() to veto it; otherwise the local overlay advances optimistically). Seeking is a no-op until a duration is known.

Click a non-interactive area of the player to focus its root, then:

Key Action Mode
Space Toggle play / pause Both
09 Seek to that tenth of the track (0 = start, 5 = halfway, 9 = 90%) Self
Seek forward 5 s Self
Seek back 5 s Self
Volume +0.1 Self
Volume −0.1 Self
m / M Toggle mute Self

Space works in external mode too — togglePlay() routes through the request-play / request-pause events so the controller decides what happens. The remaining keys depend on an owned <audio> element, so they are inert in external mode (the external controller owns volume, mute and fine seeking).

When accessibleSeek is true (the default), .waveform-container is upgraded to a standard ARIA slider. The library sets and continuously updates these attributes:

Attribute Value
role "slider"
tabindex "0"
aria-valuemin "0"
aria-valuemax Duration in seconds, rounded
aria-valuenow Current time in seconds, rounded
aria-valuetext Human-readable position, e.g. "1:23 of 3:45"
aria-label The slider’s accessible name (see below)
aria-busy "true" while the track is loading, "false" once ready

aria-valuenow / aria-valuetext refresh on every progress tick in both modes, so a screen reader always announces the live position. aria-busy lets assistive tech know a track is still decoding.

Set accessibleSeek: false to opt out entirely — the slider role, tab stop and keyboard seeking are all removed (click-to-seek on the canvas still works).

Disable the keyboard slider
new WaveformPlayer('#player', {
url: '/audio/track.mp3',
accessibleSeek: false,
});

seekLabel sets the slider’s accessible name. When unset it falls back to the track title, and finally to the literal string "Seek". The name is re-applied whenever the track changes, so a loadTrack() swap keeps the label in sync.

Explicit slider label
new WaveformPlayer('#player', {
url: '/audio/interview.mp3',
title: 'Episode 42',
seekLabel: 'Seek within Episode 42',
});
Declarative equivalent
<div
data-waveform-player
data-src="/audio/interview.mp3"
data-title="Episode 42"
data-seek-label="Seek within Episode 42"
></div>

Beyond the slider, the rendered DOM is labelled throughout:

Element CSS class Accessibility
Play / pause button .waveform-btn aria-label="Play/Pause"
Speed menu trigger .speed-btn aria-label="Playback speed"
Cue markers .waveform-marker aria-label set to each marker’s label
Error overlay .waveform-error role="alert" — announced when load fails

Marker labels come straight from your markers data, so give each cue a meaningful label. The error overlay text comes from errorText (escaped) and is announced live via role="alert".

Focus is never suppressed for keyboard users. The stylesheet ships :focus-visible rings on each interactive surface, so a mouse click stays quiet while Tab navigation shows a clear indicator:

Selector Ring
.waveform-container:focus-visible 2px solid currentColor (the seek slider)
.waveform-btn:focus-visible 2px solid currentColor, 2px offset
.waveform-marker:focus-visible 2px solid currentColor, 1px offset
.waveform-player:focus-visible 1px solid var(--wfp-accent), 1px offset

The ring colour is driven by --wfp-accent (and currentColor for the slider/button/markers), so it tracks your theme automatically. Override it per player or globally:

Custom focus accent
.waveform-player {
--wfp-accent: #6366f1;
}

The stylesheet respects the OS “reduce motion” setting. Under @media (prefers-reduced-motion: reduce), every transition and animation inside .waveform-player is neutralised:

Shipped in waveform-player.css
@media (prefers-reduced-motion: reduce) {
.waveform-player *,
.waveform-player *::before,
.waveform-player *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

State still changes instantly (play icon, progress fill, markers) — only the easing and the loading spinner’s motion are removed. No configuration is required; this applies as soon as you import the stylesheet.

In self mode, a player registers with the Media Session API so the OS lock screen, notification shade, keyboard media keys, headset buttons and browser media hub can control and label playback. This is gated on enableMediaSession (default true) and on the browser actually supporting navigator.mediaSession.

The player publishes metadata from your existing options:

Media Session field Source option
title title (falls back to "Unknown Track")
artist artist
album album
artwork artwork (advertised at 512x512)

And wires these action handlers:

Action Behaviour
play / pause play() / pause()
seekbackward Seek back 10 s
seekforward Seek forward 10 s
seekto Seek to the requested absolute time
Rich system controls
new WaveformPlayer('#player', {
url: '/audio/track.mp3',
title: 'Midnight City',
artist: 'M83',
album: 'Hurry Up, We\u2019re Dreaming',
artwork: '/art/m83.jpg',
enableMediaSession: true, // default
});

External mode keeps full accessibility even without an owned <audio> element:

  • The seek slider still renders with role="slider" and full ARIA values, driven by the external clock via setProgress().
  • Arrow / Page / Home / End seeking and Space still work — they dispatch cancelable waveformplayer:request-seek / request-play / request-pause events for the controller to honour.
  • aria-valuenow / aria-valuetext update from setProgress(currentTime, duration), so announcements stay accurate against the external source of truth.

What does not apply in external mode: digit-key seeking, arrow-key volume, m mute, and Media Session — those need a player-owned <audio> element.

Option Type Default Purpose
accessibleSeek boolean true Expose the waveform as a keyboard-seekable role="slider".
seekLabel string | null null Slider accessible name; falls back to title, then "Seek".
enableMediaSession boolean true Register system Media Session controls (self mode only).
title string | null null Media Session title + slider name fallback.
artist string | null null Media Session artist.
album string '' Media Session album metadata.
artwork string | null null Media Session artwork (and the 40×40 info image).
errorText string 'Unable to load audio' Message announced in the role="alert" overlay.

See Options for the full surface and Audio modes for how external-mode events drive the slider.