Music Player
Audio players — a spinning vinyl record, a transport-control card, horizontal and vertical Spotify-style cards, and an album crate.
Preview
@props([
'src' => null,
'cover' => null,
'title' => 'Midnight Drive',
'artist' => 'The Synthwaves',
'autoplay' => false,
])
<div
x-data="uiVinylPlayer({ src: {{ $src ? \Illuminate\Support\Js::from($src) : 'null' }}, autoplay: {{ $autoplay ? 'true' : 'false' }} })"
data-slot="music-player-01"
{{ $attributes->merge(['class' => 'mx-auto flex w-full max-w-md flex-col items-center gap-6 text-foreground sm:flex-row sm:items-center']) }}
>
{{-- Turntable: the record is the interactive element. --}}
<div class="relative mx-auto aspect-square w-56 shrink-0 sm:w-64">
{{-- The vinyl record (a real button so it is keyboard + screen-reader friendly). --}}
<button
type="button"
@click="toggle()"
:aria-pressed="playing"
:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'"
aria-label="{{ __('Play') }}"
class="ui-vinyl-disc group relative block aspect-square w-full overflow-hidden rounded-full border border-border shadow-lg outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring motion-safe:animate-[ui-vinyl-spin_4s_linear_infinite] [animation-play-state:paused]"
:class="{ '[animation-play-state:running]': playing, '[animation-play-state:paused]': !playing }"
>
{{-- Blurred cover fill behind the grooves. --}}
@if ($cover)
<img src="{{ $cover }}" alt="" aria-hidden="true" class="absolute inset-0 size-full scale-110 object-cover opacity-40 blur-xl" />
@else
<span aria-hidden="true" class="absolute inset-0 bg-gradient-to-br from-primary/50 via-accent/40 to-secondary/50 blur-xl"></span>
@endif
{{-- Concentric vinyl grooves. --}}
<span aria-hidden="true" class="ui-vinyl-grooves absolute inset-0 rounded-full"></span>
{{-- Center label: cover art (or token gradient) + center hole. --}}
<span aria-hidden="true" class="absolute inset-0 m-auto flex aspect-square w-2/5 items-center justify-center overflow-hidden rounded-full border border-border/60 shadow-inner">
@if ($cover)
<img src="{{ $cover }}" alt="" class="size-full object-cover" />
@else
<span class="size-full bg-gradient-to-br from-primary via-accent to-secondary"></span>
@endif
{{-- Center hole. --}}
<span class="absolute size-3 rounded-full bg-background shadow-inner"></span>
</span>
{{-- Play/pause glyph overlay (decorative; the button label carries the meaning). --}}
<span aria-hidden="true" class="absolute inset-0 m-auto flex aspect-square w-2/5 items-center justify-center rounded-full bg-background/0 text-foreground opacity-0 transition-opacity group-hover:bg-background/30 group-hover:opacity-100">
<svg class="size-6 drop-shadow" viewBox="0 0 24 24" fill="currentColor" x-show="!playing">
<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>
<svg class="size-6 drop-shadow" viewBox="0 0 24 24" fill="currentColor" x-show="playing" x-cloak>
<path d="M7 4a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Z" />
</svg>
</span>
</button>
{{-- Tonearm: swings onto the record while playing, lifts off when paused. --}}
<div
aria-hidden="true"
class="absolute -end-2 -top-2 origin-top-right rtl:origin-top-left transition-transform duration-700 ease-out motion-reduce:transition-none"
:class="playing ? 'rotate-[26deg]' : 'rotate-0'"
>
{{-- Pivot mount. --}}
<span class="absolute end-0 top-0 size-6 rounded-full border border-border bg-card shadow-sm"></span>
{{-- Arm. --}}
<span class="absolute end-3 top-3 block h-28 w-2 -translate-y-1 rotate-[18deg] rounded-full bg-muted-foreground/70 shadow-sm"></span>
{{-- Headshell / needle. --}}
<span class="absolute end-[2.1rem] top-[6.5rem] block h-4 w-3 rotate-[18deg] rounded-sm bg-foreground shadow-sm"></span>
</div>
</div>
{{-- Track metadata. --}}
<div class="min-w-0 text-center sm:text-start">
<p class="truncate text-lg font-semibold">{{ $title }}</p>
<p class="truncate text-sm text-muted-foreground">{{ $artist }}</p>
<x-ui.badge variant="secondary" class="mt-2" x-text="playing ? '{{ __('Now playing') }}' : '{{ __('Paused') }}'">{{ __('Paused') }}</x-ui.badge>
</div>
@if ($src)
<audio x-ref="audio" preload="none" src="{{ $src }}" class="hidden"></audio>
@endif
</div>
/**
* Vinyl record music player behavior.
*
* A spinning vinyl record that toggles play/pause when clicked. State is a
* single `playing` flag that drives every visual cue (the CSS spin animation's
* play-state, the tonearm swing, the play/pause label) and, when a real audio
* source is wired, the actual <audio> element.
*
* If `src` is given the element referenced by `x-ref="audio"` is the source of
* truth: its native `play`/`pause`/`ended` events keep `playing` in sync, so
* the record stops spinning and the arm lifts when playback ends. With no
* `src` it is a silent visual demo that still spins and toggles.
*
* Self-registers on `alpine:init` so import order does not matter. Audio is
* paused in `destroy()`.
*
* Named `uiVinylPlayer` to avoid clashing with the album-art `uiMusicPlayer`
* card transport.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiVinylPlayer', (config = {}) => ({
hasAudio: !!config.src,
autoplay: config.autoplay ?? false,
playing: false,
init() {
const audio = this.$refs.audio;
if (audio) {
// Mirror the native media state so external controls or the
// track ending keep the visuals honest.
audio.addEventListener('play', () => { this.playing = true; });
audio.addEventListener('pause', () => { this.playing = false; });
audio.addEventListener('ended', () => { this.playing = false; });
}
if (this.autoplay) this.play();
},
destroy() {
const audio = this.$refs.audio;
if (audio) audio.pause();
this.playing = false;
},
play() {
const audio = this.$refs.audio;
if (audio) {
// play() resolves the `play` event which flips `playing`; if the
// browser blocks autoplay, fall back to the visual-only state.
const p = audio.play();
if (p && typeof p.catch === 'function') p.catch(() => { this.playing = true; });
} else {
this.playing = true;
}
},
pause() {
const audio = this.$refs.audio;
if (audio) audio.pause();
else this.playing = false;
},
toggle() {
if (this.playing) this.pause();
else this.play();
},
}));
});
Installation
php artisan ui:add blocks/music-player-vinyl
Registry contract
Install confidence
php artisan ui:add blocks/music-player-vinyl
writes only the generated targets below. The CLI validates each file hash before writing and
prompts before replacing local changes unless --force is used.
- Version
- 1.0.4
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/music-player-vinyl.blade.php |
| js | resources/js/ui/music-player-vinyl.js |
Registry dependencies
Package dependencies
composer: jml/brok:^0.2
npm: alpinejs
Use with AI
A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.
# Brok UI block: Music Player (`music-player-vinyl`)
A spinning vinyl record music player: the album art is the record's center label and a blurred background fill, a stylized tonearm swings onto the record while playing, and the record spins. Click the record to toggle play/pause; wires a real <audio> element when a source is given.
Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.
## Install
```bash
php artisan ui:add blocks/music-player-vinyl
```
## Render it
```blade
<x-blocks.music-player-vinyl />
```
## Props
- `src` (mixed|null, default `null`) — Audio source URL; when set, wires a real <audio> element that the disc button controls.
- `cover` (mixed|null, default `null`) — Album art URL used as the blurred backdrop and the disc's centre label; falls back to a token gradient.
- `title` (string, default `Midnight Drive`) — Track title.
- `artist` (string, default `The Synthwaves`) — Track artist.
- `autoplay` (bool, default `false`) — Starts playback automatically when a src is given.
## Use when
- Presenting a single track as a spinning vinyl record with a tonearm that swings onto the disc while playing.
- Wanting the simplest music player, driven only by a click-to-toggle play/pause control.
## Avoid when
- Needing a seek bar, transport controls, or volume; use music-player-card, music-player-spotify, or music-player-spotify-vertical.
## Rules
- Render the installed block with `<x-blocks.music-player-vinyl />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.
## Links
- Docs: https://brokui.dev/blocks/music-player-vinyl
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/music-player-vinyl.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Guidance
Use when
- Presenting a single track as a spinning vinyl record with a tonearm that swings onto the disc while playing.
- Wanting the simplest music player, driven only by a click-to-toggle play/pause control.
Avoid when
- Needing a seek bar, transport controls, or volume; use music-player-card, music-player-spotify, or music-player-spotify-vertical.
Anti-patterns
- Do not replace semantic props with conflicting utility classes.
- Do not remove labels, focus styles, or state attributes.
- Anatomy
Usage
Render the block as a component, passing data where useful:
<x-blocks.music-player-vinyl />
Built from primitives
This block composes installed <brok:*> primitives and semantic design
tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by
editing CSS variables; flip the preview to RTL to confirm it mirrors.
Source
The exact, editable files ui:add writes
into your app. The preview above renders this same source — there are no preview-only blocks.
@props([
'src' => null,
'cover' => null,
'title' => 'Midnight Drive',
'artist' => 'The Synthwaves',
'autoplay' => false,
])
<div
x-data="uiVinylPlayer({ src: {{ $src ? \Illuminate\Support\Js::from($src) : 'null' }}, autoplay: {{ $autoplay ? 'true' : 'false' }} })"
data-slot="music-player-01"
{{ $attributes->merge(['class' => 'mx-auto flex w-full max-w-md flex-col items-center gap-6 text-foreground sm:flex-row sm:items-center']) }}
>
{{-- Turntable: the record is the interactive element. --}}
<div class="relative mx-auto aspect-square w-56 shrink-0 sm:w-64">
{{-- The vinyl record (a real button so it is keyboard + screen-reader friendly). --}}
<button
type="button"
@click="toggle()"
:aria-pressed="playing"
:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'"
aria-label="{{ __('Play') }}"
class="ui-vinyl-disc group relative block aspect-square w-full overflow-hidden rounded-full border border-border shadow-lg outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring motion-safe:animate-[ui-vinyl-spin_4s_linear_infinite] [animation-play-state:paused]"
:class="{ '[animation-play-state:running]': playing, '[animation-play-state:paused]': !playing }"
>
{{-- Blurred cover fill behind the grooves. --}}
@if ($cover)
<img src="{{ $cover }}" alt="" aria-hidden="true" class="absolute inset-0 size-full scale-110 object-cover opacity-40 blur-xl" />
@else
<span aria-hidden="true" class="absolute inset-0 bg-gradient-to-br from-primary/50 via-accent/40 to-secondary/50 blur-xl"></span>
@endif
{{-- Concentric vinyl grooves. --}}
<span aria-hidden="true" class="ui-vinyl-grooves absolute inset-0 rounded-full"></span>
{{-- Center label: cover art (or token gradient) + center hole. --}}
<span aria-hidden="true" class="absolute inset-0 m-auto flex aspect-square w-2/5 items-center justify-center overflow-hidden rounded-full border border-border/60 shadow-inner">
@if ($cover)
<img src="{{ $cover }}" alt="" class="size-full object-cover" />
@else
<span class="size-full bg-gradient-to-br from-primary via-accent to-secondary"></span>
@endif
{{-- Center hole. --}}
<span class="absolute size-3 rounded-full bg-background shadow-inner"></span>
</span>
{{-- Play/pause glyph overlay (decorative; the button label carries the meaning). --}}
<span aria-hidden="true" class="absolute inset-0 m-auto flex aspect-square w-2/5 items-center justify-center rounded-full bg-background/0 text-foreground opacity-0 transition-opacity group-hover:bg-background/30 group-hover:opacity-100">
<svg class="size-6 drop-shadow" viewBox="0 0 24 24" fill="currentColor" x-show="!playing">
<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>
<svg class="size-6 drop-shadow" viewBox="0 0 24 24" fill="currentColor" x-show="playing" x-cloak>
<path d="M7 4a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Z" />
</svg>
</span>
</button>
{{-- Tonearm: swings onto the record while playing, lifts off when paused. --}}
<div
aria-hidden="true"
class="absolute -end-2 -top-2 origin-top-right rtl:origin-top-left transition-transform duration-700 ease-out motion-reduce:transition-none"
:class="playing ? 'rotate-[26deg]' : 'rotate-0'"
>
{{-- Pivot mount. --}}
<span class="absolute end-0 top-0 size-6 rounded-full border border-border bg-card shadow-sm"></span>
{{-- Arm. --}}
<span class="absolute end-3 top-3 block h-28 w-2 -translate-y-1 rotate-[18deg] rounded-full bg-muted-foreground/70 shadow-sm"></span>
{{-- Headshell / needle. --}}
<span class="absolute end-[2.1rem] top-[6.5rem] block h-4 w-3 rotate-[18deg] rounded-sm bg-foreground shadow-sm"></span>
</div>
</div>
{{-- Track metadata. --}}
<div class="min-w-0 text-center sm:text-start">
<p class="truncate text-lg font-semibold">{{ $title }}</p>
<p class="truncate text-sm text-muted-foreground">{{ $artist }}</p>
<x-ui.badge variant="secondary" class="mt-2" x-text="playing ? '{{ __('Now playing') }}' : '{{ __('Paused') }}'">{{ __('Paused') }}</x-ui.badge>
</div>
@if ($src)
<audio x-ref="audio" preload="none" src="{{ $src }}" class="hidden"></audio>
@endif
</div>
/**
* Vinyl record music player behavior.
*
* A spinning vinyl record that toggles play/pause when clicked. State is a
* single `playing` flag that drives every visual cue (the CSS spin animation's
* play-state, the tonearm swing, the play/pause label) and, when a real audio
* source is wired, the actual <audio> element.
*
* If `src` is given the element referenced by `x-ref="audio"` is the source of
* truth: its native `play`/`pause`/`ended` events keep `playing` in sync, so
* the record stops spinning and the arm lifts when playback ends. With no
* `src` it is a silent visual demo that still spins and toggles.
*
* Self-registers on `alpine:init` so import order does not matter. Audio is
* paused in `destroy()`.
*
* Named `uiVinylPlayer` to avoid clashing with the album-art `uiMusicPlayer`
* card transport.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiVinylPlayer', (config = {}) => ({
hasAudio: !!config.src,
autoplay: config.autoplay ?? false,
playing: false,
init() {
const audio = this.$refs.audio;
if (audio) {
// Mirror the native media state so external controls or the
// track ending keep the visuals honest.
audio.addEventListener('play', () => { this.playing = true; });
audio.addEventListener('pause', () => { this.playing = false; });
audio.addEventListener('ended', () => { this.playing = false; });
}
if (this.autoplay) this.play();
},
destroy() {
const audio = this.$refs.audio;
if (audio) audio.pause();
this.playing = false;
},
play() {
const audio = this.$refs.audio;
if (audio) {
// play() resolves the `play` event which flips `playing`; if the
// browser blocks autoplay, fall back to the visual-only state.
const p = audio.play();
if (p && typeof p.catch === 'function') p.catch(() => { this.playing = true; });
} else {
this.playing = true;
}
},
pause() {
const audio = this.$refs.audio;
if (audio) audio.pause();
else this.playing = false;
},
toggle() {
if (this.playing) this.pause();
else this.play();
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.4 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0