Skip to content
Brok UI

Loading…

No results

Music Player

Open source

Audio players — a spinning vinyl record, a transport-control card, horizontal and vertical Spotify-style cards, and an album crate.

Preview

5 of 5

resources/views/blocks/music-player-album-stack.blade.php Blade
@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>
resources/js/ui/music-player-album-stack.js JS
/**
 * 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

terminal
php artisan ui:add blocks/music-player-album-stack

Installs these dependencies automatically:

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

badge button section

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.

music-player-album-stack.md
# 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
heading with album-count badge overlapping cover stack ejecting spinning vinyl per cover css-only reflection shuffle button

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.music-player-album-stack />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

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.

resources/views/blocks/music-player-album-stack.blade.php Blade
@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>
resources/js/ui/music-player-album-stack.js JS
/**
 * 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