Music Player
Audio players — a spinning vinyl record, a transport-control card, horizontal and vertical Spotify-style cards, and an album crate.
Preview
@props([
'heading' => null,
'albums' => null,
])
@php
// Default record crate. Each album declares a full, literal token-gradient
// class string so the Tailwind JIT can see it (never spliced from a var),
// plus a different pairing per item for visual rhythm.
$items = $albums ?? [
['id' => 'midnight-bloom', 'title' => 'Midnight Bloom', 'artist' => 'Velvet Arc', 'art' => 'from-primary to-accent'],
['id' => 'paper-lanterns', 'title' => 'Paper Lanterns', 'artist' => 'Hazel Quartz', 'art' => 'from-accent to-secondary'],
['id' => 'slow-tide', 'title' => 'Slow Tide', 'artist' => 'Marlow', 'art' => 'from-secondary to-primary'],
['id' => 'after-hours', 'title' => 'After Hours', 'artist' => 'The Aurora Set', 'art' => 'from-primary to-secondary'],
['id' => 'soft-static', 'title' => 'Soft Static', 'artist' => 'Lantern Pines', 'art' => 'from-muted to-accent'],
];
$title = $heading ?? __("Music I'm Vibin' to");
$count = count($items);
// Fixed z-index ladder keyed by stack index — JIT-safe literal class strings
// (never concatenate a variable into a `z-[..]` arbitrary class). Capped at 9;
// the clicked card is lifted to `z-50` separately via :class so it sits on top.
$zLadder = ['z-[1]', 'z-[2]', 'z-[3]', 'z-[4]', 'z-[5]', 'z-[6]', 'z-[7]', 'z-[8]', 'z-[9]'];
@endphp
<x-ui.section
x-data="uiAlbumStack({ count: {{ $count }} })"
data-slot="album-stack"
aria-labelledby="album-stack-heading"
space="md" surface="transparent" class="w-full px-6" {{ $attributes }}
>
<div class="mx-auto flex max-w-3xl flex-col items-center text-center">
{{-- Heading + count chip --}}
<div class="flex items-center gap-4">
<h2 id="album-stack-heading" class="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
{{ $title }}
</h2>
<x-ui.badge variant="secondary" class="shrink-0 tabular-nums">
{{ trans_choice('{1} :count album|[2,*] :count albums', $count, ['count' => $count]) }}
</x-ui.badge>
</div>
<p class="mt-4 max-w-md text-sm text-muted-foreground">
{{ __('Tap a cover to spin it up. Hover to flip through the crate.') }}
</p>
{{-- The stack. Reserve vertical room above for the ejecting vinyl and
below for the reflection. Cards overlap via a negative logical
margin-inline-start applied per item (numeric inline style → RTL-safe). --}}
<div class="mt-10 flex w-full items-end justify-center overflow-x-clip pb-2 pt-24">
<ul
class="group/stack flex items-end"
role="list"
@mouseleave="hovered = null"
>
@foreach ($items as $i => $album)
@php
$z = $zLadder[min($i, count($zLadder) - 1)];
$label = __('Play :album by :artist', ['album' => $album['title'], 'artist' => $album['artist']]);
@endphp
<li
class="relative shrink-0 transition-[margin] duration-300 {{ $z }}"
:class="open === {{ $i }} ? 'z-50' : ''"
@style([
'margin-inline-start: ' . ($i === 0 ? '0' : '-2.75rem'),
])
@mouseenter="hovered = {{ $i }}"
>
{{-- Spinning vinyl: ejects upward from behind the cover when open. --}}
<div
class="pointer-events-none absolute inset-x-0 -top-2 z-0 flex justify-center transition-all duration-500 ease-out"
:class="open === {{ $i }} ? '-translate-y-[4.5rem] opacity-100' : 'translate-y-6 opacity-0'"
aria-hidden="true"
>
<div
class="relative grid size-28 place-items-center rounded-full bg-gradient-to-br {{ $album['art'] }} shadow-lg ring-1 ring-border motion-safe:animate-spin"
:class="open === {{ $i }} ? 'motion-safe:[animation-play-state:running]' : 'motion-safe:[animation-play-state:paused]'"
style="animation-duration: 4s"
>
{{-- grooves --}}
<span class="absolute inset-2 rounded-full border border-background/20"></span>
<span class="absolute inset-5 rounded-full border border-background/20"></span>
{{-- centre label + spindle hole --}}
<span class="grid size-9 place-items-center rounded-full bg-card ring-1 ring-border">
<span class="size-2 rounded-full bg-foreground/70"></span>
</span>
</div>
</div>
{{-- The album cover: a real, keyboard-operable button. --}}
<button
type="button"
data-slot="album-stack-cover"
class="group/cover relative z-10 block size-32 origin-bottom overflow-hidden rounded-lg bg-gradient-to-br {{ $album['art'] }} shadow-md ring-1 ring-border outline-none transition-all duration-300 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:hover:-translate-y-3 motion-safe:hover:scale-105 sm:size-36"
:class="(hovered !== null && hovered !== {{ $i }}) ? 'opacity-50' : 'opacity-100'"
aria-label="{{ $label }}"
:aria-pressed="open === {{ $i }} ? 'true' : 'false'"
@click="toggle({{ $i }})"
>
{{-- decorative music note watermark --}}
<svg class="absolute bottom-3 end-3 size-8 text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
{{-- soft sheen --}}
<span class="pointer-events-none absolute inset-0 bg-gradient-to-t from-background/30 to-transparent opacity-0 transition-opacity duration-300 group-hover/cover:opacity-100" aria-hidden="true"></span>
</button>
{{-- CSS-only reflection: a flipped, alpha-masked copy of the
same gradient. The mask is a transparency gradient
(alpha only, no colour) so it does not trip the
raw-colour guard. --}}
<div
class="pointer-events-none mt-2 h-12 w-32 -scale-y-100 rounded-md bg-gradient-to-br {{ $album['art'] }} opacity-40 transition-opacity duration-300 sm:w-36 [mask-image:linear-gradient(to_bottom,black,transparent)] [-webkit-mask-image:linear-gradient(to_bottom,black,transparent)]"
:class="(hovered !== null && hovered !== {{ $i }}) ? 'opacity-20' : 'opacity-40'"
aria-hidden="true"
></div>
</li>
@endforeach
</ul>
</div>
{{-- Decorative shuffle-style control (no network, no playback). --}}
<div class="mt-8">
<x-ui.button variant="ghost" size="sm" @click="shuffle()">
<svg class="size-4 motion-safe:transition-transform" :class="spun ? 'rotate-180' : ''" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 3h5v5" />
<path d="M4 20 21 3" />
<path d="M21 16v5h-5" />
<path d="m15 15 6 6" />
<path d="M4 4l5 5" />
</svg>
{{ __('Shuffle') }}
</x-ui.button>
</div>
</div>
</x-ui.section>
/**
* Album stack behaviour (UI demo, no real playback or network).
*
* Tracks which cover is "open" (its vinyl ejected and spinning) and which is
* hovered (so neighbours can dim). Only ONE vinyl is open at a time: clicking a
* closed card opens it and retracts any previously open one; clicking the open
* card closes it. The clicked card is raised above its siblings purely in the
* Blade via a `z-50` :class binding keyed on `open`.
*
* Self-registers on `alpine:init` so import order does not matter. No window or
* document listeners, timers, observers or RAF loops are added; `init()` /
* `destroy()` are present to satisfy the lifecycle contract and to leave a clear
* teardown seam if listeners are added later.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiAlbumStack', (config = {}) => ({
// Total number of covers in the stack (passed from Blade).
count: config.count ?? 0,
// Index of the currently ejected/spinning cover, or null when none.
open: null,
// Index of the hovered cover, or null. Drives neighbour dimming.
hovered: null,
// Decorative flag toggled by the shuffle control to spin its icon.
spun: false,
init() {
// No external listeners/timers to wire up; here for lifecycle parity.
},
destroy() {
// Nothing to tear down. Reset transient state defensively.
this.open = null;
this.hovered = null;
},
// Toggle a cover. Opening a different one retracts the previous (single
// open at a time); clicking the open cover closes it.
toggle(index) {
this.open = this.open === index ? null : index;
},
// Decorative shuffle: closes any open vinyl and flips the icon. No audio,
// no network, no reordering of the (server-rendered) crate.
shuffle() {
this.open = null;
this.spun = !this.spun;
},
}));
});
Installation
php artisan ui:add blocks/music-player-album-stack
Registry contract
Install confidence
php artisan ui:add blocks/music-player-album-stack
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-album-stack.blade.php |
| js | resources/js/ui/music-player-album-stack.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: Album Stack (`music-player-album-stack`)
A stacked row of album covers that lift on hover (dimming their neighbours), pop a spinning vinyl disc when clicked, and cast a CSS-only album-art reflection.
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-album-stack
```
## Render it
```blade
<x-blocks.music-player-album-stack />
```
## Props
- `heading` (mixed|null, default `null`) — Section heading; null defaults to "Music I'm Vibin' to".
- `albums` (mixed|null, default `null`) — Rows of {id, title, artist, art}; null falls back to a 5-album sample crate.
## Use when
- Displaying a browsable crate of multiple albums as an overlapping stack of covers that lift on hover and dim their neighbours.
- Wanting a decorative, click-to-spin vinyl interaction per cover rather than a working audio player.
## Avoid when
- Needing an actual playable track with a seek bar or transport controls; use music-player-vinyl through music-player-spotify-vertical.
## Rules
- Render the installed block with `<x-blocks.music-player-album-stack />` 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-album-stack
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/music-player-album-stack.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
- Displaying a browsable crate of multiple albums as an overlapping stack of covers that lift on hover and dim their neighbours.
- Wanting a decorative, click-to-spin vinyl interaction per cover rather than a working audio player.
Avoid when
- Needing an actual playable track with a seek bar or transport controls; use music-player-vinyl through 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-album-stack />
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([
'heading' => null,
'albums' => null,
])
@php
// Default record crate. Each album declares a full, literal token-gradient
// class string so the Tailwind JIT can see it (never spliced from a var),
// plus a different pairing per item for visual rhythm.
$items = $albums ?? [
['id' => 'midnight-bloom', 'title' => 'Midnight Bloom', 'artist' => 'Velvet Arc', 'art' => 'from-primary to-accent'],
['id' => 'paper-lanterns', 'title' => 'Paper Lanterns', 'artist' => 'Hazel Quartz', 'art' => 'from-accent to-secondary'],
['id' => 'slow-tide', 'title' => 'Slow Tide', 'artist' => 'Marlow', 'art' => 'from-secondary to-primary'],
['id' => 'after-hours', 'title' => 'After Hours', 'artist' => 'The Aurora Set', 'art' => 'from-primary to-secondary'],
['id' => 'soft-static', 'title' => 'Soft Static', 'artist' => 'Lantern Pines', 'art' => 'from-muted to-accent'],
];
$title = $heading ?? __("Music I'm Vibin' to");
$count = count($items);
// Fixed z-index ladder keyed by stack index — JIT-safe literal class strings
// (never concatenate a variable into a `z-[..]` arbitrary class). Capped at 9;
// the clicked card is lifted to `z-50` separately via :class so it sits on top.
$zLadder = ['z-[1]', 'z-[2]', 'z-[3]', 'z-[4]', 'z-[5]', 'z-[6]', 'z-[7]', 'z-[8]', 'z-[9]'];
@endphp
<x-ui.section
x-data="uiAlbumStack({ count: {{ $count }} })"
data-slot="album-stack"
aria-labelledby="album-stack-heading"
space="md" surface="transparent" class="w-full px-6" {{ $attributes }}
>
<div class="mx-auto flex max-w-3xl flex-col items-center text-center">
{{-- Heading + count chip --}}
<div class="flex items-center gap-4">
<h2 id="album-stack-heading" class="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
{{ $title }}
</h2>
<x-ui.badge variant="secondary" class="shrink-0 tabular-nums">
{{ trans_choice('{1} :count album|[2,*] :count albums', $count, ['count' => $count]) }}
</x-ui.badge>
</div>
<p class="mt-4 max-w-md text-sm text-muted-foreground">
{{ __('Tap a cover to spin it up. Hover to flip through the crate.') }}
</p>
{{-- The stack. Reserve vertical room above for the ejecting vinyl and
below for the reflection. Cards overlap via a negative logical
margin-inline-start applied per item (numeric inline style → RTL-safe). --}}
<div class="mt-10 flex w-full items-end justify-center overflow-x-clip pb-2 pt-24">
<ul
class="group/stack flex items-end"
role="list"
@mouseleave="hovered = null"
>
@foreach ($items as $i => $album)
@php
$z = $zLadder[min($i, count($zLadder) - 1)];
$label = __('Play :album by :artist', ['album' => $album['title'], 'artist' => $album['artist']]);
@endphp
<li
class="relative shrink-0 transition-[margin] duration-300 {{ $z }}"
:class="open === {{ $i }} ? 'z-50' : ''"
@style([
'margin-inline-start: ' . ($i === 0 ? '0' : '-2.75rem'),
])
@mouseenter="hovered = {{ $i }}"
>
{{-- Spinning vinyl: ejects upward from behind the cover when open. --}}
<div
class="pointer-events-none absolute inset-x-0 -top-2 z-0 flex justify-center transition-all duration-500 ease-out"
:class="open === {{ $i }} ? '-translate-y-[4.5rem] opacity-100' : 'translate-y-6 opacity-0'"
aria-hidden="true"
>
<div
class="relative grid size-28 place-items-center rounded-full bg-gradient-to-br {{ $album['art'] }} shadow-lg ring-1 ring-border motion-safe:animate-spin"
:class="open === {{ $i }} ? 'motion-safe:[animation-play-state:running]' : 'motion-safe:[animation-play-state:paused]'"
style="animation-duration: 4s"
>
{{-- grooves --}}
<span class="absolute inset-2 rounded-full border border-background/20"></span>
<span class="absolute inset-5 rounded-full border border-background/20"></span>
{{-- centre label + spindle hole --}}
<span class="grid size-9 place-items-center rounded-full bg-card ring-1 ring-border">
<span class="size-2 rounded-full bg-foreground/70"></span>
</span>
</div>
</div>
{{-- The album cover: a real, keyboard-operable button. --}}
<button
type="button"
data-slot="album-stack-cover"
class="group/cover relative z-10 block size-32 origin-bottom overflow-hidden rounded-lg bg-gradient-to-br {{ $album['art'] }} shadow-md ring-1 ring-border outline-none transition-all duration-300 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:hover:-translate-y-3 motion-safe:hover:scale-105 sm:size-36"
:class="(hovered !== null && hovered !== {{ $i }}) ? 'opacity-50' : 'opacity-100'"
aria-label="{{ $label }}"
:aria-pressed="open === {{ $i }} ? 'true' : 'false'"
@click="toggle({{ $i }})"
>
{{-- decorative music note watermark --}}
<svg class="absolute bottom-3 end-3 size-8 text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
{{-- soft sheen --}}
<span class="pointer-events-none absolute inset-0 bg-gradient-to-t from-background/30 to-transparent opacity-0 transition-opacity duration-300 group-hover/cover:opacity-100" aria-hidden="true"></span>
</button>
{{-- CSS-only reflection: a flipped, alpha-masked copy of the
same gradient. The mask is a transparency gradient
(alpha only, no colour) so it does not trip the
raw-colour guard. --}}
<div
class="pointer-events-none mt-2 h-12 w-32 -scale-y-100 rounded-md bg-gradient-to-br {{ $album['art'] }} opacity-40 transition-opacity duration-300 sm:w-36 [mask-image:linear-gradient(to_bottom,black,transparent)] [-webkit-mask-image:linear-gradient(to_bottom,black,transparent)]"
:class="(hovered !== null && hovered !== {{ $i }}) ? 'opacity-20' : 'opacity-40'"
aria-hidden="true"
></div>
</li>
@endforeach
</ul>
</div>
{{-- Decorative shuffle-style control (no network, no playback). --}}
<div class="mt-8">
<x-ui.button variant="ghost" size="sm" @click="shuffle()">
<svg class="size-4 motion-safe:transition-transform" :class="spun ? 'rotate-180' : ''" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 3h5v5" />
<path d="M4 20 21 3" />
<path d="M21 16v5h-5" />
<path d="m15 15 6 6" />
<path d="M4 4l5 5" />
</svg>
{{ __('Shuffle') }}
</x-ui.button>
</div>
</div>
</x-ui.section>
/**
* Album stack behaviour (UI demo, no real playback or network).
*
* Tracks which cover is "open" (its vinyl ejected and spinning) and which is
* hovered (so neighbours can dim). Only ONE vinyl is open at a time: clicking a
* closed card opens it and retracts any previously open one; clicking the open
* card closes it. The clicked card is raised above its siblings purely in the
* Blade via a `z-50` :class binding keyed on `open`.
*
* Self-registers on `alpine:init` so import order does not matter. No window or
* document listeners, timers, observers or RAF loops are added; `init()` /
* `destroy()` are present to satisfy the lifecycle contract and to leave a clear
* teardown seam if listeners are added later.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiAlbumStack', (config = {}) => ({
// Total number of covers in the stack (passed from Blade).
count: config.count ?? 0,
// Index of the currently ejected/spinning cover, or null when none.
open: null,
// Index of the hovered cover, or null. Drives neighbour dimming.
hovered: null,
// Decorative flag toggled by the shuffle control to spin its icon.
spun: false,
init() {
// No external listeners/timers to wire up; here for lifecycle parity.
},
destroy() {
// Nothing to tear down. Reset transient state defensively.
this.open = null;
this.hovered = null;
},
// Toggle a cover. Opening a different one retracts the previous (single
// open at a time); clicking the open cover closes it.
toggle(index) {
this.open = this.open === index ? null : index;
},
// Decorative shuffle: closes any open vinyl and flips the icon. No audio,
// no network, no reordering of the (server-rendered) crate.
shuffle() {
this.open = null;
this.spun = !this.spun;
},
}));
});
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