Music Player
Audio players — a spinning vinyl record, a transport-control card, horizontal and vertical Spotify-style cards, and an album crate.
Preview
@props([
'track' => [
'title' => 'Paper Lanterns',
'artist' => 'Hazel Quartz',
'art' => 'from-accent to-secondary',
'durationSeconds' => 178,
],
])
@php
$track = array_merge([
'title' => 'Paper Lanterns',
'artist' => 'Hazel Quartz',
'art' => 'from-accent to-secondary',
'durationSeconds' => 178,
], $track);
$config = [
'track' => $track,
];
@endphp
<section
x-data="uiSpotifyCardVertical({{ Js::from($config) }})"
data-slot="spotify-card-vertical"
aria-label="{{ __('Music player') }}"
{{ $attributes->merge(['class' => 'group/card relative mx-auto flex aspect-[3/4] w-full max-w-xs flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-lg']) }}
>
{{-- Album art region: blurred base + sharp mask-faded cover + scrim --}}
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
{{-- Heavily blurred base wash --}}
<div
class="absolute inset-0 scale-110 bg-gradient-to-br blur-2xl"
:class="track.art"
></div>
{{-- Sharp album cover, faded out toward the bottom via an alpha mask --}}
<div
class="absolute inset-x-0 top-0 flex h-[68%] items-center justify-center bg-gradient-to-br"
:class="track.art"
style="-webkit-mask-image: linear-gradient(to bottom, black 55%, transparent 100%); mask-image: linear-gradient(to bottom, black 55%, transparent 100%);"
>
<svg class="size-20 text-primary-foreground drop-shadow-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
{{-- Equalizer bars while playing (motion-safe, decorative) --}}
<div
class="absolute bottom-4 end-4 hidden items-end gap-2 motion-safe:flex"
x-show="playing"
x-cloak
>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.6rem; animation-delay: 0ms"></span>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.25rem; animation-delay: 150ms"></span>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.85rem; animation-delay: 300ms"></span>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.1rem; animation-delay: 450ms"></span>
</div>
</div>
{{-- Bottom scrim so controls stay legible over the cover --}}
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-card via-card/85 to-transparent"></div>
</div>
{{-- Controls panel --}}
<div class="relative mt-auto flex flex-col gap-4 p-6">
{{-- Track metadata --}}
<div class="min-w-0">
<h3 class="truncate text-lg font-semibold leading-tight text-foreground" x-text="track.title">Paper Lanterns</h3>
<p class="truncate text-sm text-muted-foreground" x-text="track.artist">Hazel Quartz</p>
</div>
{{-- Seek bar --}}
<div>
<div
x-ref="seek"
role="slider"
aria-label="{{ __('Seek') }}"
aria-valuemin="0"
:aria-valuemax="duration"
:aria-valuenow="Math.round(currentTime)"
:aria-valuetext="fmt(currentTime) + ' {{ __('of') }} ' + fmt(duration)"
tabindex="0"
class="group relative flex h-4 w-full cursor-pointer items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
@pointerdown="seek(fractionFromEvent($event, $refs.seek))"
@keydown.left.prevent="nudge(-5)"
@keydown.right.prevent="nudge(5)"
@keydown.home.prevent="seek(0)"
@keydown.end.prevent="seek(1)"
>
<div class="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-muted"></div>
<div
class="absolute start-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-primary"
:style="`width: ${progress * 100}%`"
></div>
<div
class="absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-primary opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
:style="`inset-inline-start: ${progress * 100}%`"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span x-text="fmt(currentTime)">0:00</span>
<span x-text="fmt(duration)">2:58</span>
</div>
</div>
{{-- Transport controls --}}
<div class="flex items-center justify-center gap-4">
<x-ui.button variant="ghost" size="icon" aria-label="{{ __('Previous') }}" x-on:click="prev()">
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 5a1 1 0 0 1 2 0v5.4l8.5-5.3A1 1 0 0 1 18 6v12a1 1 0 0 1-1.5.86L8 13.6V19a1 1 0 1 1-2 0V5Z" />
</svg>
</x-ui.button>
<x-ui.button variant="default" size="icon-lg" class="rounded-full shadow-md" aria-label="{{ __('Play') }}" x-bind:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'" x-on:click="toggle()">
<svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" 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" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" 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>
</x-ui.button>
<x-ui.button variant="ghost" size="icon" aria-label="{{ __('Next') }}" x-on:click="next()">
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 5a1 1 0 0 0-2 0v5.4L7.5 5.1A1 1 0 0 0 6 6v12a1 1 0 0 0 1.5.86L16 13.6V19a1 1 0 1 0 2 0V5Z" />
</svg>
</x-ui.button>
</div>
</div>
</section>
/**
* Vertical Spotify card behavior (UI demo, no real <audio>).
*
* Holds a single track ({ title, artist, durationSeconds, art }) plus play/pause
* and elapsed-time state. A 1s ticker advances `currentTime` while playing and
* stops at the end. The seek bar is driven by `seek(fraction)` / `nudge()`, wired
* to pointer + keyboard events in the Blade markup. `prev()`/`next()` are exposed
* for the transport buttons; with no playlist they simply restart the track.
*
* Self-registers on `alpine:init` so import order does not matter. The ticker is
* cleared in `destroy()` so unmounting mid-playback cannot leak a timer.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSpotifyCardVertical', (config = {}) => ({
track: config.track ?? {
title: 'Paper Lanterns',
artist: 'Hazel Quartz',
art: 'from-accent to-secondary',
durationSeconds: 178,
},
playing: false,
currentTime: 0,
timer: null,
init() {
this.timer = setInterval(() => this.tick(), 1000);
},
destroy() {
if (this.timer) clearInterval(this.timer);
},
get duration() {
return this.track.durationSeconds || 0;
},
// Played fraction (0..1) used to size the filled portion of the seek bar.
get progress() {
return this.duration ? Math.min(1, this.currentTime / this.duration) : 0;
},
toggle() {
this.playing = !this.playing;
},
// Restart the track (single-track demo has no playlist to advance through).
next() {
this.currentTime = 0;
},
prev() {
this.currentTime = 0;
},
// Seek to an absolute fraction (0..1) of the track.
seek(fraction) {
const f = Math.max(0, Math.min(1, fraction));
this.currentTime = Math.round(f * this.duration);
},
// Arrow-key seeking: nudge elapsed time by `step` seconds.
nudge(step) {
this.currentTime = Math.max(0, Math.min(this.duration, this.currentTime + step));
},
// Resolve a pointer event to a 0..1 fraction along a horizontal track,
// honouring RTL where the visual origin is on the right.
fractionFromEvent(event, el) {
const rect = el.getBoundingClientRect();
if (!rect.width) return 0;
let f = (event.clientX - rect.left) / rect.width;
if (getComputedStyle(el).direction === 'rtl') f = 1 - f;
return Math.max(0, Math.min(1, f));
},
tick() {
if (!this.playing) return;
if (this.currentTime + 1 >= this.duration) {
this.currentTime = this.duration;
this.playing = false; // stop at end of the single track
return;
}
this.currentTime += 1;
},
// Format seconds as m:ss.
fmt(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
const m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
},
}));
});
Installation
php artisan ui:add blocks/music-player-spotify-vertical
Registry contract
Install confidence
php artisan ui:add blocks/music-player-spotify-vertical
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.3
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/music-player-spotify-vertical.blade.php |
| js | resources/js/ui/music-player-spotify-vertical.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: Spotify Card Vertical (`music-player-spotify-vertical`)
A tall vertical Spotify-style music card with a progressive blur-fade album cover that blends into a dark controls panel holding track metadata, a seekable progress bar, and transport controls.
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-spotify-vertical
```
## Render it
```blade
<x-blocks.music-player-spotify-vertical />
```
## Props
- `track` (array, default `[…]`) — Track object {title, artist, art, durationSeconds}; merges over defaults so partial overrides work.
## Use when
- Presenting a tall, poster-style Spotify card with a progressive blur-fade album cover blending into a dark controls panel.
- Wanting a single fixed track with seek and transport controls in a 3:4 aspect layout.
## Avoid when
- Needing a horizontal layout with size variants, or a volume slider and playlist; use music-player-spotify for horizontal, or music-player-card for volume and playlist.
## Rules
- Render the installed block with `<x-blocks.music-player-spotify-vertical />` 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-spotify-vertical
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/music-player-spotify-vertical.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 tall, poster-style Spotify card with a progressive blur-fade album cover blending into a dark controls panel.
- Wanting a single fixed track with seek and transport controls in a 3:4 aspect layout.
Avoid when
- Needing a horizontal layout with size variants, or a volume slider and playlist; use music-player-spotify for horizontal, or music-player-card for volume and playlist.
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-spotify-vertical />
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([
'track' => [
'title' => 'Paper Lanterns',
'artist' => 'Hazel Quartz',
'art' => 'from-accent to-secondary',
'durationSeconds' => 178,
],
])
@php
$track = array_merge([
'title' => 'Paper Lanterns',
'artist' => 'Hazel Quartz',
'art' => 'from-accent to-secondary',
'durationSeconds' => 178,
], $track);
$config = [
'track' => $track,
];
@endphp
<section
x-data="uiSpotifyCardVertical({{ Js::from($config) }})"
data-slot="spotify-card-vertical"
aria-label="{{ __('Music player') }}"
{{ $attributes->merge(['class' => 'group/card relative mx-auto flex aspect-[3/4] w-full max-w-xs flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-lg']) }}
>
{{-- Album art region: blurred base + sharp mask-faded cover + scrim --}}
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
{{-- Heavily blurred base wash --}}
<div
class="absolute inset-0 scale-110 bg-gradient-to-br blur-2xl"
:class="track.art"
></div>
{{-- Sharp album cover, faded out toward the bottom via an alpha mask --}}
<div
class="absolute inset-x-0 top-0 flex h-[68%] items-center justify-center bg-gradient-to-br"
:class="track.art"
style="-webkit-mask-image: linear-gradient(to bottom, black 55%, transparent 100%); mask-image: linear-gradient(to bottom, black 55%, transparent 100%);"
>
<svg class="size-20 text-primary-foreground drop-shadow-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
{{-- Equalizer bars while playing (motion-safe, decorative) --}}
<div
class="absolute bottom-4 end-4 hidden items-end gap-2 motion-safe:flex"
x-show="playing"
x-cloak
>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.6rem; animation-delay: 0ms"></span>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.25rem; animation-delay: 150ms"></span>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.85rem; animation-delay: 300ms"></span>
<span class="block w-1 rounded-full bg-primary-foreground/70 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.1rem; animation-delay: 450ms"></span>
</div>
</div>
{{-- Bottom scrim so controls stay legible over the cover --}}
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-card via-card/85 to-transparent"></div>
</div>
{{-- Controls panel --}}
<div class="relative mt-auto flex flex-col gap-4 p-6">
{{-- Track metadata --}}
<div class="min-w-0">
<h3 class="truncate text-lg font-semibold leading-tight text-foreground" x-text="track.title">Paper Lanterns</h3>
<p class="truncate text-sm text-muted-foreground" x-text="track.artist">Hazel Quartz</p>
</div>
{{-- Seek bar --}}
<div>
<div
x-ref="seek"
role="slider"
aria-label="{{ __('Seek') }}"
aria-valuemin="0"
:aria-valuemax="duration"
:aria-valuenow="Math.round(currentTime)"
:aria-valuetext="fmt(currentTime) + ' {{ __('of') }} ' + fmt(duration)"
tabindex="0"
class="group relative flex h-4 w-full cursor-pointer items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
@pointerdown="seek(fractionFromEvent($event, $refs.seek))"
@keydown.left.prevent="nudge(-5)"
@keydown.right.prevent="nudge(5)"
@keydown.home.prevent="seek(0)"
@keydown.end.prevent="seek(1)"
>
<div class="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-muted"></div>
<div
class="absolute start-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-primary"
:style="`width: ${progress * 100}%`"
></div>
<div
class="absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-primary opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
:style="`inset-inline-start: ${progress * 100}%`"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span x-text="fmt(currentTime)">0:00</span>
<span x-text="fmt(duration)">2:58</span>
</div>
</div>
{{-- Transport controls --}}
<div class="flex items-center justify-center gap-4">
<x-ui.button variant="ghost" size="icon" aria-label="{{ __('Previous') }}" x-on:click="prev()">
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 5a1 1 0 0 1 2 0v5.4l8.5-5.3A1 1 0 0 1 18 6v12a1 1 0 0 1-1.5.86L8 13.6V19a1 1 0 1 1-2 0V5Z" />
</svg>
</x-ui.button>
<x-ui.button variant="default" size="icon-lg" class="rounded-full shadow-md" aria-label="{{ __('Play') }}" x-bind:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'" x-on:click="toggle()">
<svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" 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" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" 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>
</x-ui.button>
<x-ui.button variant="ghost" size="icon" aria-label="{{ __('Next') }}" x-on:click="next()">
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 5a1 1 0 0 0-2 0v5.4L7.5 5.1A1 1 0 0 0 6 6v12a1 1 0 0 0 1.5.86L16 13.6V19a1 1 0 1 0 2 0V5Z" />
</svg>
</x-ui.button>
</div>
</div>
</section>
/**
* Vertical Spotify card behavior (UI demo, no real <audio>).
*
* Holds a single track ({ title, artist, durationSeconds, art }) plus play/pause
* and elapsed-time state. A 1s ticker advances `currentTime` while playing and
* stops at the end. The seek bar is driven by `seek(fraction)` / `nudge()`, wired
* to pointer + keyboard events in the Blade markup. `prev()`/`next()` are exposed
* for the transport buttons; with no playlist they simply restart the track.
*
* Self-registers on `alpine:init` so import order does not matter. The ticker is
* cleared in `destroy()` so unmounting mid-playback cannot leak a timer.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSpotifyCardVertical', (config = {}) => ({
track: config.track ?? {
title: 'Paper Lanterns',
artist: 'Hazel Quartz',
art: 'from-accent to-secondary',
durationSeconds: 178,
},
playing: false,
currentTime: 0,
timer: null,
init() {
this.timer = setInterval(() => this.tick(), 1000);
},
destroy() {
if (this.timer) clearInterval(this.timer);
},
get duration() {
return this.track.durationSeconds || 0;
},
// Played fraction (0..1) used to size the filled portion of the seek bar.
get progress() {
return this.duration ? Math.min(1, this.currentTime / this.duration) : 0;
},
toggle() {
this.playing = !this.playing;
},
// Restart the track (single-track demo has no playlist to advance through).
next() {
this.currentTime = 0;
},
prev() {
this.currentTime = 0;
},
// Seek to an absolute fraction (0..1) of the track.
seek(fraction) {
const f = Math.max(0, Math.min(1, fraction));
this.currentTime = Math.round(f * this.duration);
},
// Arrow-key seeking: nudge elapsed time by `step` seconds.
nudge(step) {
this.currentTime = Math.max(0, Math.min(this.duration, this.currentTime + step));
},
// Resolve a pointer event to a 0..1 fraction along a horizontal track,
// honouring RTL where the visual origin is on the right.
fractionFromEvent(event, el) {
const rect = el.getBoundingClientRect();
if (!rect.width) return 0;
let f = (event.clientX - rect.left) / rect.width;
if (getComputedStyle(el).direction === 'rtl') f = 1 - f;
return Math.max(0, Math.min(1, f));
},
tick() {
if (!this.playing) return;
if (this.currentTime + 1 >= this.duration) {
this.currentTime = this.duration;
this.playing = false; // stop at end of the single track
return;
}
this.currentTime += 1;
},
// Format seconds as m:ss.
fmt(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
const m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.3 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0