Audio Player
A working audio player around a native <audio> element: play/pause, a seekable scrub-bar with time read-outs, playback-rate cycling and a mute toggle. Localised labels, RTL-aware, reduced-motion safe.
Preview
Sample track
ElevenLabs UI
<div class="mx-auto w-full max-w-md">
<x-ui.audio-player
src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
:title="__('Sample track')"
:artist="__('ElevenLabs UI')"
/>
</div>
Installation
php artisan ui:add audio-player
Note
This component ships an Alpine behavior module at
resources/js/ui/audio-player.js. Import it once from your bundle so it registers on alpine:init:
import './audio-player.js';
Registry contract
php artisan ui:add audio-player
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/audio-player.blade.php -
resources/js/ui/audio-player.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Audio Player (`audio-player`)
A working audio player around a native <audio> element: play/pause, a seekable scrub-bar with time read-outs, playback-rate cycling and a mute toggle. Localised labels, RTL-aware, reduced-motion safe.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add audio-player
```
## Usage
```blade
<div class="mx-auto w-full max-w-md">
<x-ui.audio-player
src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
:title="__('Sample track')"
:artist="__('ElevenLabs UI')"
/>
</div>
```
## Props
- `src` (string, default ``)
- `title` (mixed|null, default `null`)
- `artist` (mixed|null, default `null`)
- `rates` (array, default `[0.5, 1, 1.5, 2]`)
- `autoplay` (bool, default `false`)
## Use when
- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Playing a single track, podcast episode or voice note with real transport controls.
- Giving a native audio element a token-styled surface with a title and artist.
## Avoid when
- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Provide keyboard controls, visible playback state, time information, and a transcript or equivalent where practical.
- Building a playlist or queue; compose several players or a media block instead.
- Seeking without playback; scrub-bar is the standalone control.
## Anti-patterns
- Autoplay with sound
- Audio as the only access to essential information
## Rules
- Use the `<brok:audio-player>` tag (or `<x-ui.audio-player>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/audio-player
- Registry JSON (files, props, contract): https://brokui.dev/r/open/audio-player.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | ||
| title | mixed | null | null | |
| artist | mixed | null | null | |
| rates | array | [0.5, 1, 1.5, 2] | |
| autoplay | bool | false |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Play, pause, seek, playback-rate cycling and mute drive the wrapped native audio element, so the browser keeps its own controls and media session.
- The scrub-bar follows playback time and seeks on drag or keyboard; the current and total time read out beside it.
- rates lists the playback rates the speed control cycles through.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Play, inspect, record, or dictate temporal audio.
Use when
- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Playing a single track, podcast episode or voice note with real transport controls.
- Giving a native audio element a token-styled surface with a title and artist.
Avoid when
- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Provide keyboard controls, visible playback state, time information, and a transcript or equivalent where practical.
- Building a playlist or queue; compose several players or a media block instead.
- Seeking without playback; scrub-bar is the standalone control.
Use instead
- Silent scannable controls
- Transcript or text input
Anti-patterns
- Autoplay with sound
- Audio as the only access to essential information
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Every transport control is a button with a translated label that changes with state, and mute reports aria-pressed.
- The volume slider exposes its value as a percentage through aria-valuetext.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="audio-player-{{ $record->id }}">
<div class="mx-auto w-full max-w-md">
<x-ui.audio-player
src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
:title="__('Sample track')"
:artist="__('ElevenLabs UI')"
/>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:audio-player
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'src' => '',
'title' => null,
'artist' => null,
'rates' => [0.5, 1, 1.5, 2],
'autoplay' => false,
])
@php
// A working audio player: a native <audio> element driven by Alpine, with a
// play/pause control (x-ui.button), a seek <x-ui.scrub-bar>, time read-outs,
// a playback-rate menu and a mute toggle with a hover volume slider. State of truth is the media
// element; the scrubber is a controlled view wired two-ways by events —
// `scrub` (up) seeks the audio, `scrubset` (down) reflects timeupdate.
//
// User-facing labels are localised here and passed into the Alpine scope so
// the dynamic aria-labels stay translatable without leaking Blade into JS.
$rates = collect(is_array($rates) ? $rates : [1])
->map(fn ($r) => (float) $r)
->filter(fn ($r) => $r > 0)
->values();
$rates = $rates->isEmpty() ? collect([1.0]) : $rates;
$config = [
'src' => (string) $src,
'rates' => $rates->all(),
'autoplay' => (bool) $autoplay,
'labels' => [
'play' => __('Play'),
'pause' => __('Pause'),
'mute' => __('Mute'),
'unmute' => __('Unmute'),
'speed' => __('Playback speed'),
],
];
@endphp
<div
data-slot="audio-player"
x-data="uiAudioPlayer(@js($config))"
@scrub="seek($event.detail.time)"
@scrubstart="seeking = true"
@scrubend="seeking = false"
{{ $attributes->merge(['class' => 'bg-card text-card-foreground flex w-full max-w-md flex-col gap-3 rounded-lg border border-border p-6 shadow-sm']) }}
>
<audio
x-ref="audio"
preload="metadata"
class="sr-only"
@loadedmetadata="onLoaded()"
@durationchange="onLoaded()"
@timeupdate="onTimeUpdate()"
@play="playing = true"
@pause="playing = false"
@ended="onEnded()"
@waiting="buffering = true"
@playing="buffering = false"
@ratechange="rate = $event.target.playbackRate"
></audio>
@if ($title || $artist)
<div class="flex flex-col gap-0.5 overflow-hidden">
@if ($title)
<p data-slot="audio-player-title" class="truncate text-sm font-medium text-foreground">{{ $title }}</p>
@endif
@if ($artist)
<p data-slot="audio-player-artist" class="truncate text-xs text-muted-foreground">{{ $artist }}</p>
@endif
</div>
@endif
<div class="flex items-center gap-3">
<x-ui.button
size="icon"
variant="default"
class="shrink-0"
@click="toggle()"
::aria-label="playing ? labels.pause : labels.play"
::data-state="playing ? 'playing' : 'paused'"
>
{{-- Buffering spinner --}}
<svg x-show="buffering" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="none" class="size-5 motion-safe:animate-spin">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" class="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
{{-- Play --}}
<svg x-show="!playing && !buffering" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
{{-- Pause --}}
<svg x-show="playing && !buffering" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path d="M7 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H7Zm8 0a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2Z" />
</svg>
</x-ui.button>
<span data-slot="audio-player-current" class="w-10 shrink-0 text-end text-xs tabular-nums text-muted-foreground" x-text="format(current)">0:00</span>
<x-ui.scrub-bar x-ref="scrub" class="grow" :label="__('Seek')" />
<span data-slot="audio-player-duration" class="w-10 shrink-0 text-xs tabular-nums text-muted-foreground" x-text="format(duration)">0:00</span>
{{-- Playback speed: a menu of the configured rates, the active one checked. --}}
<x-ui.dropdown class="shrink-0">
<x-ui.dropdown.trigger
class="h-9 min-w-11 rounded-md px-2 text-xs font-medium tabular-nums text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
::aria-label="`${labels.speed}: ${rate}×`"
x-text="`${rate}×`"
>1×</x-ui.dropdown.trigger>
<x-ui.dropdown.content align="end" class="min-w-32">
<x-ui.dropdown.label>{{ __('Speed') }}</x-ui.dropdown.label>
<x-ui.dropdown.radio-group>
@foreach ($rates as $r)
<x-ui.dropdown.radio-item
::aria-checked="rate === {{ $r }}"
::data-state="rate === {{ $r }} ? 'checked' : 'unchecked'"
@click="setRate({{ $r }})"
class="tabular-nums"
>{{ rtrim(rtrim(number_format($r, 2), '0'), '.') }}×</x-ui.dropdown.radio-item>
@endforeach
</x-ui.dropdown.radio-group>
</x-ui.dropdown.content>
</x-ui.dropdown>
{{-- Volume: the mute toggle, with a level slider that slides open on
hover or focus (YouTube-style) so the row stays compact. --}}
<div data-slot="audio-player-volume" class="group/volume flex shrink-0 items-center">
<x-ui.button
size="icon"
variant="ghost"
class="shrink-0"
@click="toggleMute()"
::aria-label="muted ? labels.unmute : labels.mute"
::aria-pressed="muted"
>
<svg x-show="!muted && volume > 0.5" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
<path d="M11 5 6 9H2v6h4l5 4V5Z" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
<svg x-show="!muted && volume <= 0.5" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
<path d="M11 5 6 9H2v6h4l5 4V5Z" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
<svg x-show="muted" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
<path d="M11 5 6 9H2v6h4l5 4V5Z" />
<line x1="22" y1="9" x2="16" y2="15" />
<line x1="16" y1="9" x2="22" y2="15" />
</svg>
</x-ui.button>
<div class="flex w-0 items-center overflow-hidden transition-[width] duration-200 ease-out group-hover/volume:w-24 group-focus-within/volume:w-24 motion-reduce:transition-none">
<div class="shrink-0 ps-1 pe-2">
<input
type="range"
min="0"
max="1"
step="0.05"
aria-label="{{ __('Volume') }}"
::aria-valuetext="`${Math.round(volume * 100)}%`"
x-bind:value="muted ? 0 : volume"
x-bind:style="{ '--v': Math.round((muted ? 0 : volume) * 100) + '%' }"
@input="setVolume($event.target.value)"
{{-- A styled native range: a 4px track whose fill stops at --v and a
12px round thumb centred on it, so the two line up in every
browser (the stock control draws them on different baselines). --}}
class="block h-3 w-20 cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-none
[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--color-primary)_var(--v),var(--color-secondary)_var(--v))]
[&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-sm
[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-secondary [&::-moz-range-progress]:h-1 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-primary
[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-primary
focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring"
/>
</div>
</div>
</div>
</div>
</div>
/**
* Audio Player behaviour.
*
* Wraps a native <audio> element (the single source of truth) and exposes the
* reactive state the chrome binds to: playing / buffering / muted / current /
* duration / rate. The <x-ui.scrub-bar> child is a controlled view wired by
* events:
* • scrub-bar → player: `scrub` seeks the audio (handled in Blade).
* • player → scrub-bar: every timeupdate we dispatch `scrubset` onto the
* scrub-bar root so its fill/thumb mirror playback. While the user is
* actively dragging (`seeking`), we stop pushing so we don't fight them.
*
* The media element's own listeners are declared in the template (x-on), so
* Alpine attaches and removes them automatically; destroy() just pauses so a
* unit unmounting mid-playback goes quiet.
*
* Self-registers on `alpine:init`. Plain JS — no Blade syntax.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiAudioPlayer', (config = {}) => ({
src: config.src || '',
rates: Array.isArray(config.rates) && config.rates.length ? config.rates : [1],
autoplay: !!config.autoplay,
labels: config.labels || {},
playing: false,
buffering: false,
muted: false,
seeking: false,
current: 0,
duration: 0,
rate: 1,
rateIndex: 0,
volume: 1,
_lastVolume: 1,
init() {
const audio = this.$refs.audio;
if (!audio) return;
// Pick the index whose rate is 1× (the natural default), else first.
const oneIdx = this.rates.indexOf(1);
this.rateIndex = oneIdx >= 0 ? oneIdx : 0;
this.rate = this.rates[this.rateIndex];
audio.playbackRate = this.rate;
audio.muted = this.muted;
audio.volume = this.volume;
if (this.src) {
audio.src = this.src;
audio.load();
}
if (this.autoplay && this.src) {
// Autoplay may be blocked; swallow the rejection.
this.$nextTick(() => audio.play().catch(() => {}));
}
},
get isFiniteDuration() {
return Number.isFinite(this.duration) && this.duration > 0;
},
format(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
const ss = String(s).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
},
// The scrub-bar starts its OWN Alpine scope, so its `x-ref` is not visible
// in this component's `$refs`. Resolve it by data-slot within our root
// instead (cached) — robust regardless of Alpine scope boundaries.
scrubEl() {
if (!this._scrubEl) {
this._scrubEl = this.$root.querySelector('[data-slot="scrub-bar"]');
}
return this._scrubEl;
},
// Reflect playback position + duration down to the scrub-bar (no re-emit).
pushScrub() {
const scrub = this.scrubEl();
if (!scrub) return;
scrub.dispatchEvent(
new CustomEvent('scrubset', {
detail: { time: this.current, duration: this.isFiniteDuration ? this.duration : 0 },
})
);
},
onLoaded() {
const audio = this.$refs.audio;
this.duration = Number.isFinite(audio?.duration) ? audio.duration : 0;
this.pushScrub();
},
onTimeUpdate() {
const audio = this.$refs.audio;
if (!audio) return;
this.current = audio.currentTime || 0;
if (!this.seeking) this.pushScrub();
},
onEnded() {
this.playing = false;
},
toggle() {
const audio = this.$refs.audio;
if (!audio || !this.src) return;
if (audio.paused) {
audio.play().catch(() => {});
} else {
audio.pause();
}
},
seek(time) {
const audio = this.$refs.audio;
if (!audio || !this.isFiniteDuration) return;
const t = Math.min(this.duration, Math.max(0, Number(time) || 0));
audio.currentTime = t;
this.current = t;
},
cycleRate() {
this.setRate(this.rates[(this.rateIndex + 1) % this.rates.length]);
},
setRate(rate) {
const audio = this.$refs.audio;
const idx = this.rates.indexOf(Number(rate));
this.rateIndex = idx >= 0 ? idx : 0;
this.rate = this.rates[this.rateIndex];
if (audio) audio.playbackRate = this.rate;
},
toggleMute() {
const audio = this.$refs.audio;
this.muted = !this.muted;
if (audio) audio.muted = this.muted;
// Unmuting at zero volume would stay silent: restore the last level.
if (!this.muted && this.volume === 0) this.setVolume(this._lastVolume || 1);
},
// 0..1. Sliding to zero mutes; sliding back up unmutes.
setVolume(level) {
const audio = this.$refs.audio;
this.volume = Math.min(1, Math.max(0, Number(level) || 0));
if (this.volume > 0) this._lastVolume = this.volume;
if (audio) {
audio.volume = this.volume;
audio.muted = this.volume === 0;
}
this.muted = this.volume === 0;
},
destroy() {
const audio = this.$refs.audio;
if (audio && !audio.paused) audio.pause();
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0