Audio Player
Playback for sound with no picture. The waveform is the content, the list is the hard case, and one clip plays at a time.
Also called Waveform, Waveform Player, Sound Player, Voice Preview, Audio Scrubber — in this system all of them are Audio Player.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The hard case: a list
Four voices being compared. Each row is a 28px player; starting one stops the others. This is the arrangement that decides the component — a single player is easy and is not what breaks.
A library is a table, not a stack of cards
The same four voices as columns. The card version above gives every row two left edges and cuts the waveform to whatever is left of the line — so the shapes are different scales and cannot be compared, which is the one thing a media library exists to let you do. Here name, waveform and duration line up down the page and the waveform takes the slack, so a shape is the length of its sample. The play control is outlined at rest and fills while playing: in a list of twenty that fill is how you find the row you are hearing without reading any of them.
What a row may drop
Name and duration are each optional, and the overflow menu is not the player’s to decide. Turn "User owns these" off to see the read-only case: no menu, and no actions column reserved for one. A disabled button says "this is yours, later"; an absent one says "this was never yours" — which for a shared default voice is the truth.
Why a waveform and not a bar
The same two clips. On the left you can see which one opens with four seconds of silence and which one is clipping; on the right they are the same grey rectangle.
One per view
When the clip is the reason the page exists, it gets the 64px waveform, its own time row, and the controls a listener reaches for over several minutes: skip, mute, speed.
What a list costs
Decoding peaks downloads the whole file. Lazy mode shows the seeded shape immediately and decodes the real one on hover, focus or play — so the rows a reader never looks at cost nothing.
Against the native element
<audio controls> is the right answer for one clip on a plain page. In a list it is 54px of unstyleable chrome per row, it looks different in every browser, and nothing stops two of them playing at once.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
The full player: waveform, elapsed and total time, then a transport row with the secondary controls pushed to the edges so the play button stays centred.
- Waveform64px full · 28px row
The content surface. Below about 24px the peaks stop resolving into a shape and it is a textured bar; above 80px it is an instrument panel in a page that is not one.
- Bar count96–120 full · 48–72 row
Derived from width, not fixed: bars narrower than 1px alias into a grey smear. Roughly one bar per 3–4px of width.
- Played fillaccent
The only state that must be readable across forty rows at a glance. It answers "which one is playing?", which is why it takes the accent rather than a neutral.
- Playhead1.5px, foreground
Drawn only once position > 0. At 0:00 it is a stray tick on the left edge of every idle row.
- Transport48px full · 28px row
The play button is the one control that must never wait for anything — not for peaks, not for metadata.
- Timetabular-nums
Two labels in the full player, one in a row. Proportional digits make the readout jitter on every tick.
- Secondary controlsMute, speed, ±10s
Full player only. In a row they would be 20px targets in a dense table, so they are dropped rather than shrunk.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Waveform | ||
| --ds-accent | — | Played portion of the waveform and the play button fill |
| --ds-border-strong | — | Unplayed bars — present, quiet, never competing with the played half |
| --ds-fg-disabled | — | Unplayed bars when there is no source |
| --ds-fg | — | The playhead |
| Controls | ||
| --ds-fg-on-accent | — | The play glyph |
| --ds-accent-hover | — | Play button hover |
| --ds-fg-muted | — | Time readout |
| --ds-focus-ring | — | Focus ring on the scrubber and every control |
| Surfaces | ||
| --ds-surface-inset | — | Full-player shell |
| --ds-border-subtle | — | Full-player border |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-2 | Gap between transport, waveform and time |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Full-player shell | |
| --radius-full | — | Play button and bar caps |
Typography
| Token | Value | Used for |
|---|---|---|
| tabular-nums | — | Every time readout, so it does not jitter as it counts |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Label gap | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|---|---|
| Row | 28px | — | — | 28px play | 8px | — | — | — | 44px on coarse pointers | One cell of a list row. Play, waveform, one time label — nothing else. |
| Full | 64px waveform | 16px | 12px | 48px play | — | — | — | — | — | One per view, when the clip is why the page exists. |
| Bars (row) | — | — | — | — | — | — | 48 | 72 | — | Roughly one per 3–4px of width. Fewer at narrow widths, never more. |
| Bars (full) | — | — | — | — | — | — | 96 | 120 | — | Enough to resolve a phrase; more is a smear at any realistic width. |
| Play button | 28px row · 48px full | — | — | — | — | — | — | — | — | Filled with the accent. It is the only affordance that is never ambiguous. |
| Time | — | — | — | — | — | 11px | 36px row · none full | — | — | Fixed width in a row so the waveform does not resize as the clock counts. |
aria-label="Play Arthur"
aria-valuetext="0:42 of 3:34"role="slider" tabindex="0"
← → ±5s · ⇧ ±30s · Home / Endplayers.forEach(p => p !== active && p.pause())rows.forEach(r => decodeAudioData(r)) → 120 MBawait peaks; audio.play() → 3s of nothingNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The played and unplayed halves of the waveform must differ by more than hue — the split is what tells a reader where they are, and it is the first thing to disappear for a colour-blind user.
- Unplayed bars still need 3:1 against the row background. Bars drawn at 15% alpha look elegant and vanish on a projector.
- The playhead is a 1.5px line: it needs the strongest foreground in the palette, not a mid-grey.
- The focus ring goes on the scrubber itself, not on a wrapper — the wrapper is usually the full row and the ring then describes the wrong thing.
Keyboard
| Space / Enter | Toggle playback when the scrubber or the play button has focus. |
| ← / → | Seek by five seconds. ↑ / ↓ do the same, since a slider is expected to answer both axes. |
| Shift + ← / → | Seek by thirty seconds — the coarse pass through a long clip. |
| Home / End | Jump to the start, or to the last second rather than to the end event. |
| Tab | Play button, then scrubber, then the secondary controls. Two stops per row, not seven. |
Screen readers
- Announce the clip with its length: "Arthur, audio, 26 seconds". A row that announces only "Play" is indistinguishable from the thirty-nine below it.
- Never announce elapsed time continuously. A live region on the clock is the fastest way to make a player unusable.
- Audio-only content needs a transcript to satisfy 1.2.1. For generated speech that is free — it is the script you sent to the model.
Focus & touch
- The play button and the scrubber are two tab stops per player; the secondary controls exist only on the full player, so a list never costs more than two stops per row. Focus must survive a re-render — a list that rebuilds its rows while one is playing has to move the live player into the new node rather than recreate it, or the keyboard user loses both the control and the audio.
- The play button is 28px in a row for density and gets a 44px ::after overlay on coarse pointers, per the touch-target rule. The waveform is already at least 44px wide and is the seek target, so it needs no expansion — but it must not be the whole row, or a tap meant to select the row scrubs the audio instead.
| Attribute | Applied to | Notes |
|---|---|---|
| role="slider" | The waveform | It is a canvas or a stack of spans: without this it has no role, no value and no keyboard. |
| tabindex="0" | The waveform | Neither a canvas nor a div is focusable by default, so the scrubber is mouse-only until this is set. |
| aria-valuenow / aria-valuetext | The waveform | "0:42 of 3:34". A bare percentage conveys nothing about a clip. |
| aria-label | Play and the scrubber | Include the track name. In a list the bare verb is repeated for every row. |
| aria-disabled | The scrubber with no source | Disabled rather than absent, so its position in the row is stable. |
Example usage
1// One per view. Decodes real peaks as soon as it has a source.2const player = new AudioPlayer({ mount: el, variant: 'full' })3player.load('/uploads/chapter-3.mp3')45// In a list. Two differences, both load-bearing:6// lazyWaveform — the row costs nothing until the reader shows interest7// label — "Play Arthur", not the forty-fold "Play"8const row = new AudioPlayer({9 mount: cell,10 variant: 'compact',11 lazyWaveform: true,12 bars: 56,13 label: track.title,14})1516// attach() points it at a source WITHOUT fetching: the seeded shape and the17// duration you already have from the API are enough to render a real,18// scrubbable row before a byte is spent.19row.attach(track.src, { duration: track.duration_sec })2021// A list that re-renders must MOVE its players, not rebuild them. A dropped22// player keeps playing with nothing left to stop it, and still holds its slot23// in the exclusivity registry — so it silences whatever you start next.24if (players.has(id)) players.get(id).remount(cell)25else players.set(id, new AudioPlayer({ mount: cell, variant: 'compact' }))26for (const [id, p] of players) if (!visible.has(id)) { p.destroy(); players.delete(id) }Framework-free HTML
<!-- One clip on an otherwise plain page: use the native element. It brings
keyboard control, the platform's own accessibility integration, and a
download affordance you would otherwise have to build. -->
<figure>
<audio controls preload="metadata" src="/uploads/chapter-3.mp3"></audio>
<figcaption>
Chapter 3 · 3 min 34 s ·
<a href="/transcripts/chapter-3">Read the transcript</a>
</figcaption>
</figure>
<!-- A themed player is the markup below. The waveform is a canvas because a
list of forty is forty canvases rather than forty times seventy DOM nodes,
and it is a slider because a canvas is otherwise a picture. -->
<div class="ds-audio ds-audio--row">
<button class="ds-audio__play" type="button" aria-label="Play Arthur">…</button>
<canvas
class="ds-audio__wave"
tabindex="0"
role="slider"
aria-label="Seek Arthur"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
aria-valuetext="0:00 of 0:26"
></canvas>
<span class="ds-audio__time">0:26</span>
</div>CSS
.ds-audio {
display: flex;
align-items: center;
gap: var(--space-2);
min-inline-size: 0; /* or the waveform pushes the row wider than its cell */
user-select: none;
}
/* The scarce thing in a list is vertical space, so the row variant is one line. */
.ds-audio--row .ds-audio__wave { block-size: 28px; }
.ds-audio--full .ds-audio__wave { block-size: 64px; }
.ds-audio__wave {
flex: 1 1 auto;
min-inline-size: 0;
cursor: pointer;
border-radius: var(--radius-sm);
}
/* A canvas is not focusable and has no value semantics. role="slider" plus a
real focus ring is what makes it a control rather than a picture. */
.ds-audio__wave:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
.ds-audio__play {
display: grid;
place-items: center;
flex: 0 0 auto;
inline-size: 28px;
block-size: 28px;
border-radius: var(--radius-full);
background: var(--ds-accent);
color: var(--ds-fg-on-accent);
}
.ds-audio--full .ds-audio__play { inline-size: 48px; block-size: 48px; }
.ds-audio__play:hover { background: var(--ds-accent-hover); }
.ds-audio__play:disabled { opacity: 0.4; }
/* Fixed width, tabular figures: the waveform must not resize as the clock
counts, and the digits must not jitter. */
.ds-audio__time {
flex: 0 0 auto;
min-inline-size: 36px;
text-align: end;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--ds-fg-muted);
}
/* 28px is right for density and wrong for a thumb. The overlay makes the target
44px without inflating the row. */
@media (pointer: coarse) {
.ds-audio__play { position: relative; }
.ds-audio__play::after {
content: '';
position: absolute;
inset: 50% auto auto 50%;
inline-size: 44px;
block-size: 44px;
translate: -50% -50%;
}
}Component API
AudioPlayer
| Prop | Type | Default | Description |
|---|---|---|---|
| mount* | Element | string | — | The element the player renders itself into. The <audio> is created outside it, so the mount can be replaced without stopping playback. |
| audio | HTMLAudioElement | — | Adopt an existing element instead of creating one, so a caller keeping its own src/play logic keeps working. |
| variant | 'full' | 'compact' | 'full' | 'compact' is the 28px row: play, waveform, one time label. It drops mute, speed and skip rather than shrinking them. |
| lazyWaveform | boolean | false | Required in lists. preload="none" plus a seeded shape until hover, focus or play; then metadata and the real peaks. |
| bars | number | 120 | Bar count. Roughly one per 3–4px of expected width — narrower than 1px and they alias into a smear. |
| label | string | — | The track name, folded into every aria-label. Without it a list announces "Play" forty times. |
| skipSeconds | number | 10 | Full-player skip buttons. Ten for speech, thirty for long-form is the usual pair. |
| attach(url, {duration}) | method | — | Point at a source without fetching it. The duration you already have makes the row real and scrubbable before any bytes are spent. |
| load(url) | method | — | Set the source and fetch it now. The eager counterpart of attach(). |
| remount(el) | method | — | Move the UI into a new element, keeping the audio and its playback. What a re-rendering list must call instead of constructing a new player. |
| destroy() | method | — | Pause, unregister, and empty the mount. A player left behind keeps playing and keeps silencing the next one you start. |
Professional tips
- Show the duration before playback and the position after it. In a row that is one label doing two jobs, and it is the difference between a list you can read and a column of zeroes.
- Seed the placeholder shape from the source URL, never from a random number: the same clip must look the same on every render, or the shape stops being an identifier.
- Take the duration from your own API when you have it. It makes the row scrubbable before the media loads, and the media’s own duration overrides it on arrival.
- Draw the playhead only once playback has moved. At zero it is a tick on the left edge of every idle row.
- For generated speech, publish the script as the transcript. It satisfies 1.2.1 for free and is usually what the user wants to copy anyway.
Performance
- Peaks require the entire file: fetch, arrayBuffer, decodeAudioData. That is the one expensive thing in this component and the only one worth designing around.
- Share a single AudioContext across every player. Browsers cap them at around six, and a list of rows exhausts that on its own.
- preload="none" for lazy rows, then "metadata" on first interest. "auto" in a list will saturate the connection before anyone presses play.
- Redraw on timeupdate — about four times a second — not on requestAnimationFrame. A playhead does not need 60fps and a list of canvases certainly does not.
- Cache decoded peaks by URL. Scrolling a list back and forth otherwise re-downloads and re-decodes the same files.
- Size the canvas by devicePixelRatio and redraw on resize, or the bars are soft on every retina display.
Common mistakes
- Decoding every row on page load, which turns a list into a hundred-megabyte download.
- Rebuilding players when a list re-renders, leaving orphans that keep playing and keep silencing the next clip.
- A waveform with no role, no tabindex and no keys — a scrubber only a mouse can reach.
- aria-valuenow as a bare percentage, so a screen reader announces "34" for a position.
- Native <audio controls> in a table, which is unstyleable, differently sized in every browser, and happy to play four clips at once.
- A play button that waits for the decode, so the first press appears to do nothing.
- Unplayed bars at 15% alpha: elegant on the designer’s monitor, invisible on a projector.
Real-world recommendations
- The waveform earns its cost in exactly one place — a list where clips are being compared. For a single clip on a plain page, the native element is a better answer than anything custom.
- People use the shape to skip the silence at the front of a generated clip. That is the most common interaction with a voice preview and it is impossible with a progress bar.
- Exclusive playback is noticed only when it is missing: nobody remarks that one clip stops the last one, and everybody notices four playing at once.
- A seeded placeholder shape is indistinguishable from a real waveform for the first second of attention, which is all it needs to survive — and it is why lazy decoding does not read as a downgrade.