Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
0:26

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.

ArthurWarm, unhurried
0:26
ImogenBright, precise
0:19
NadiaLow, documentary
0:31
SørenDry, conversational
0:22

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.

Cards — every waveform a different scale
ArthurWarm, unhurried
0:26
ImogenBright, precise
0:19
NadiaLow, documentary
0:31
SørenDry, conversational
0:22
Columns — shapes comparable down the page
VoiceDuration
Arthur
0:08
Charlotte
0:09
Edward
0:07
Janet
0:06

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.

Row variant
VoiceDuration
Arthur
0:08
Charlotte
0:09
Edward
0:07
Janet
0:06

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.

WaveformThe clip identifies itself
0:26
0:18
Progress barTwo identical rows
0:00
0:00

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.

0:003:34

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.

eager, 40 rows≈ 120 MB and 40 decodes on page load
lazy, 40 rows0 bytes until the pointer enters a row
play before peaks landaudio starts; the shape catches up
no waveform until decodedan empty row that looks broken

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.

Row player28px, themed, exclusive
0:26
Native controls54px, per-browser, concurrent

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.

0:26
IdleShows the length, not 0:00
Playing
Played / unplayed
Playhead
0:00
No sourceFlat line, control disabled
Focused scrubber
0:42 / 3:34
Time
1.5×
Speed

Anatomy

Every part, every measurement, and the reason it is that number.

0:003:34

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.

  1. 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.

  2. 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.

  3. 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.

  4. Playhead1.5px, foreground

    Drawn only once position > 0. At 0:00 it is a stray tick on the left edge of every idle row.

  5. Transport48px full · 28px row

    The play button is the one control that must never wait for anything — not for peaks, not for metadata.

  6. Timetabular-nums

    Two labels in the full player, one in a row. Proportional digits make the readout jitter on every tick.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-2Gap between transport, waveform and time

Radius

TokenValueUsed for
--radius-lgFull-player shell
--radius-full—Play button and bar caps

Typography

TokenValueUsed for
tabular-nums—Every time readout, so it does not jitter as it counts

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconLabel gapTypeMin widthMax widthTouch targetWhen to use
Row28px——28px play8px———44px on coarse pointersOne cell of a list row. Play, waveform, one time label — nothing else.
Full64px waveform16px12px48px play—————One per view, when the clip is why the page exists.
Bars (row)——————4872—Roughly one per 3–4px of width. Fewer at narrow widths, never more.
Bars (full)——————96120—Enough to resolve a phrase; more is a smear at any realistic width.
Play button28px row · 48px full————————Filled with the accent. It is the only affordance that is never ambiguous.
Time—————11px36px row · none full——Fixed width in a row so the waveform does not resize as the clock counts.

Do

0:22
Draw a shape before you have the real oneA shape seeded from the URL is deterministic, arrives instantly, and reads as audio. An empty row while forty files decode reads as broken.
aria-label="Play Arthur"
aria-valuetext="0:42 of 3:34"
Name the track in every labelForty buttons that all announce "Play" are forty identical rows to a screen reader. "Play Arthur" is the row.
role="slider" tabindex="0"
← → ±5s · ⇧ ±30s · Home / End
Make the waveform a real sliderA canvas cannot take focus and has no value. role="slider" with arrow keys is what stops the scrubber being mouse-only.
players.forEach(p => p !== active && p.pause())
Stop the others when one startsTwo clips over each other is never intended, and a list makes it one click away. Keep the registry in the component, not in each list.

Don't

rows.forEach(r => decodeAudioData(r)) → 120 MB
Do not decode every row on page loadPeaks require the whole file. Forty three-megabyte tracks is a hundred megabytes fetched to draw pictures nobody has looked at yet.
Do not put native controls in a list54px of chrome per row, a different look in every browser, no exclusivity, and no way to theme the one element the reader is comparing across rows.
▶🔇1×⏪⏩
Do not shrink the full player to fit a rowMute, speed and skip at row height are 20px targets in a dense table. A control too small to hit is worse than one that is not there.
0:00 · 0:00 · 0:00
Do not show 0:00 as a row’s only labelA column of zeroes tells the reader nothing. Before playback the one label is the duration; after it starts, the position.
await peaks; audio.play() → 3s of nothing
Do not block play on the waveformPeaks are decoration for the first second and content afterwards. A play button that waits for a decode is a player that feels broken on a slow connection.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.2Audio ControlA2.1.1KeyboardA1.2.1Audio-only (Prerecorded)A4.1.2Name, Role, ValueA

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 / EnterToggle 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 / EndJump to the start, or to the last second rather than to the end event.
TabPlay 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.
AttributeApplied toNotes
role="slider"The waveformIt is a canvas or a stack of spans: without this it has no role, no value and no keyboard.
tabindex="0"The waveformNeither a canvas nor a div is focusable by default, so the scrubber is mouse-only until this is set.
aria-valuenow / aria-valuetextThe waveform"0:42 of 3:34". A bare percentage conveys nothing about a clip.
aria-labelPlay and the scrubberInclude the track name. In a list the bare verb is repeated for every row.
aria-disabledThe scrubber with no sourceDisabled rather than absent, so its position in the row is stable.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
audioHTMLAudioElement—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.
lazyWaveformbooleanfalseRequired in lists. preload="none" plus a seeded shape until hover, focus or play; then metadata and the real peaks.
barsnumber120Bar count. Roughly one per 3–4px of expected width — narrower than 1px and they alias into a smear.
labelstring—The track name, folded into every aria-label. Without it a list announces "Play" forty times.
skipSecondsnumber10Full-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.

Notes

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.