Canvas Design System
Main Site Tokens

Audio Player

Plays one voice note back inline: a round play button, a scrub bar, a duration, and an optional delete.

info The helper renders markup only; the behaviour ships separately. ws_audio_player() emits a real <audio preload="none"> plus data-player-action hooks. Play, pause, scrub, the progress fill and the delete confirmation all live in FacilitatorVoiceNotes (facilitator/js/modules/voice-notes.js), which is not part of components.js. The examples on this page are therefore static: the buttons will not play anything here.

When to Use

Use when: Playing back a saved voice note beside the thing it belongs to: an agenda item's note list, an exercise's notes, a session record. It is sized for a list row, so several stack cleanly.
Don't use when: The audio is long-form or is the page's main content. A podcast episode or a lesson recording wants a full transport with a seek time readout and speed control, not a 28px button. For capturing audio rather than playing it, see Audio Recorder.

Variants

There is no variant option. What changes between these is which of the four content options are present, and each one adds or removes a part.

Source only

The minimum call. With no duration the time reads 0:00 until playback starts and the JS begins writing remaining time into it.

0:00
<?= ws_audio_player('/uploads/voice_notes/file.webm') ?>

With a duration

Pass seconds, get m:ss. Always pass it: the stored value is known server-side, and without it the row shows a false 0:00 for a note that is two minutes long.

0:45
2:18
<?= ws_audio_player($note['file_path'], ['duration' => $note['duration']]) ?>

With a label

The label sits under the scrub bar in --text-micro and truncates with an ellipsis rather than wrapping, so the row keeps its height whatever the note is called.

Energy dipped after lunch
0:45
Stakeholder pushback on the second option, worth replaying before the readout
1:32
<?= ws_audio_player($note['file_path'], [
    'duration' => $note['duration'],
    'label'    => $note['label'],
]) ?>

Deletable

deletable adds the trailing close button; note_id is what makes it work. The JS reads data-note-id off the root and does nothing at all when it is missing, so the two options only make sense together.

Energy dipped after lunch
0:45
<?= ws_audio_player($note['file_path'], [
    'duration'  => $note['duration'],
    'label'     => $note['label'],
    'note_id'   => $note['id'],
    'deletable' => true,
]) ?>

Narrow column

The scrub bar takes the slack, so the control survives a sidebar width. The label truncates first, then the bar shortens.

Energy dipped after lunch
0:45

States

One class carries the only state: .ws-audio-player--playing, which swaps the glyph. Everything else is the browser's. The second example below is the playing state, faked through the class option plus a demo rule on the fill, since nothing here is actually playing.

Paused (default)
0:45
Playing
0:45
<?= ws_audio_player($src, ['class' => 'ws-audio-player--playing']) ?>
<!-- In production the JS adds and removes this class; do not ship it. -->
StateBehavior
PausedDefault. play_arrow shows, pause is hidden by the :not(--playing) rule as well as by its own hidden attribute.
Playing.ws-audio-player--playing on the root swaps the glyphs. The JS pauses every other player on the page first, so two notes never talk over each other.
Progress.ws-audio-player__fill is driven by inline width from timeupdate, with a 0.1s linear transition so it moves smoothly rather than ticking.
Time readoutShows the total duration at rest and counts down the remaining time while playing. It never shows elapsed time, which surprises people reading the code.
EndedThe playing class is dropped and the fill resets to 0%. The row returns to its resting look.
SeekClicking anywhere on .ws-audio-player__progress jumps to that ratio of the duration. Pointer only, see Accessibility.
HoverThe play button darkens; the delete button turns --color-error. The row itself has no hover treatment.
Missing fileNo handling. A 404 on the src leaves a control that looks live and does nothing when pressed.
DisabledNot supported. There is no option and no styling for it.

Real-World Usage

The Facilitator's voice note list. Notes saved earlier in the session come back from the API and render server-side; notes recorded during the session are appended client-side, which is why the same markup exists twice.

Opening round ran long
0:32
Two people carried the whole discussion
1:14
Park the pricing question for the readout
1:58
<?php foreach ($notes as $note): ?>
    <?= ws_audio_player($note['file_path'], [
        'duration'  => $note['duration'],
        'label'     => $note['label'],
        'note_id'   => $note['id'],
        'deletable' => true,
    ]) ?>
<?php endforeach; ?>

<script>
// Bind after render, and again after appending any new player.
FacilitatorVoiceNotes.initPlayers(document.getElementById('voice-list'));
</script>
warning The JS builds this markup by hand as well. voice-notes.js constructs a .ws-audio-player string for notes recorded in-session rather than calling back to PHP. Any change to the template has to be mirrored there or the two copies drift, which is exactly the duplication a component is meant to prevent. Check both before editing either.

Options

OptionTypeDefaultPurpose
$srcstring''Positional and required in practice. Written to both data-src on the root and the <audio src>.
durationint0Seconds. Cast with (int) and formatted as m:ss; 0 or absent renders 0:00. The design system JSON lists this as a string, which is wrong: pass an integer.
labelstring''Caption under the scrub bar. Omitted entirely when empty, so the row shrinks rather than leaving a gap.
note_idint|string''Renders data-note-id, which the delete handler needs. Omitted when empty, so the attribute is absent rather than blank.
deletableboolfalseAdds the trailing delete button. Pair it with note_id or the button is inert.
idstring'ws-player-' . uniqid()Root element ID. Auto-generated; set it only if you need a stable handle.
classstring''Extra classes on the root.
attrsarray[]Extra HTML attributes on the root. Every entry renders as key="value"; there is no boolean-attribute form.

Accessibility

ConcernBehavior
Play buttonA real <button> with aria-label="Play audio". Gap: the label is static, so it still says "Play audio" while the note is playing and the button actually pauses.
Delete buttonUses title for its name rather than aria-label. Announced by most screen readers, but title is the weaker hook and it is invisible to touch users.
Scrub barGap. .ws-audio-player__progress is a <div> with a click handler: no tabindex, no role="slider", no arrow-key handling. Seeking is pointer-only. Play and pause are reachable, so the note is still listenable end to end.
Time readoutPlain text with no live region, which is correct here: a per-second countdown announced aloud would be unusable.
ColourThe progress fill is --color-primary on --gray-100, decorative rather than informational, and the same information is in the time readout.
Loadingpreload="none", so a list of twenty notes costs no bandwidth until one is played. Nothing signals the fetch, so a slow first press looks like nothing happened.

Tokens

TokenUsed for
--color-primaryPlay button fill and the progress fill
--text-inverseGlyph on the play button
--gray-100 / --gray-200Progress track, row border
--bg-surfaceRow ground
--text-secondary / --text-tertiaryTime readout, label, delete glyph
--color-errorDelete glyph on hover
--radius-md / --radius-smRow shape, delete hit area
--space-2 / --space-3Row padding and the gaps between parts
--font-bodyFace for label and time
--text-micro / --text-caption / --text-body / --text-h4Label, time, delete glyph, play glyph
warning One hover colour is not a real token. .ws-audio-player__btn:hover resolves var(--color-primary-dark, #651FFF), and --color-primary-dark is not defined in platform-tokens.css. Outside Coach and the detail sidebar, which each define it locally, the play button hovers to a deep purple left over from before Red Unification. Worth fixing in components.css, which this page deliberately does not do.

CSS Classes

ClassPurpose
.ws-audio-playerRoot row, carrying data-src and optionally data-note-id
.ws-audio-player--playingSet by JS during playback; swaps play for pause
.ws-audio-player__btnThe round play or pause button
.ws-audio-player__icon--play / __icon--pauseThe two glyphs, one hidden at a time
.ws-audio-player__trackColumn holding the scrub bar and the label
.ws-audio-player__progress / __fillScrub bar and its inline-width fill
.ws-audio-player__labelCaption, truncated with an ellipsis
.ws-audio-player__timeDuration at rest, remaining time while playing
.ws-audio-player__deleteTrailing delete, rendered only when deletable

The JS contract is data-player-action, with values toggle and delete, plus the <audio> element inside the root. Renaming any of those breaks playback.

Files

FilePurpose
includes/components/helpers.phpws_audio_player() helper function
includes/components/audio-player.phpTemplate, including the m:ss formatting
includes/components/components.cssStyles (.ws-audio-player rules)
facilitator/js/modules/voice-notes.jsFacilitatorVoiceNotes.initPlayers() and the hand-built markup copy
api/upload-voice-note.phpServes the note list and handles delete
design-system/components/php/AudioPlayer.phpPortable namespaced mirror of the helper