Skip to content
Brok UI

Loading…

No results

Apple Dock

Open source

A macOS-style floating, frosted dock whose icon tiles magnify and lift toward the pointer with a neighbour falloff and a tooltip label.

Version
v1.2.2
Stability
stable
License
MIT
Related
Navigation Menu
Button Group

Preview

Hover across the tiles — the one under the pointer magnifies and its neighbours scale with a falloff.

Set magnification to false for a calm, fixed-size dock.

previews.components.apple-dock.default.blade.php Blade
<div class="flex flex-col gap-6">
    {{-- Default magnifying dock --}}
    <div class="flex flex-col gap-2">
        <label class="text-sm font-medium text-foreground">{{ __('Magnifying dock') }}</label>
        <div class="flex min-h-32 items-end justify-center rounded-xl border border-border bg-muted/40 p-6">
            <x-ui.apple-dock />
        </div>
        <p class="text-xs text-muted-foreground">{{ __('Hover across the tiles — the one under the pointer magnifies and its neighbours scale with a falloff.') }}</p>
    </div>

    {{-- Magnification disabled (static dock) --}}
    <div class="flex flex-col gap-2">
        <label class="text-sm font-medium text-foreground">{{ __('Static (no magnification)') }}</label>
        <div class="flex min-h-32 items-end justify-center rounded-xl border border-border bg-muted/40 p-6">
            <x-ui.apple-dock :magnification="false" />
        </div>
        <p class="text-xs text-muted-foreground">{{ __('Set magnification to false for a calm, fixed-size dock.') }}</p>
    </div>
</div>

Installation

terminal
php artisan ui:add apple-dock

Note

This component ships an Alpine behavior module at resources/js/ui/apple-dock.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './apple-dock.js';

Registry contract

php artisan ui:add apple-dock 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.

  • blade resources/views/components/ui/apple-dock.blade.php
  • js resources/js/ui/apple-dock.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

apple-dock.md
# Brok UI: Apple Dock (`apple-dock`)

A macOS-style floating, frosted dock whose icon tiles magnify and lift toward the pointer with a neighbour falloff and a tooltip label.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add apple-dock
```

## Usage

```blade
<div class="flex flex-col gap-6">
    {{-- Default magnifying dock --}}
    <div class="flex flex-col gap-2">
        <label class="text-sm font-medium text-foreground">{{ __('Magnifying dock') }}</label>
        <div class="flex min-h-32 items-end justify-center rounded-xl border border-border bg-muted/40 p-6">
            <x-ui.apple-dock />
        </div>
        <p class="text-xs text-muted-foreground">{{ __('Hover across the tiles — the one under the pointer magnifies and its neighbours scale with a falloff.') }}</p>
    </div>

    {{-- Magnification disabled (static dock) --}}
    <div class="flex flex-col gap-2">
        <label class="text-sm font-medium text-foreground">{{ __('Static (no magnification)') }}</label>
        <div class="flex min-h-32 items-end justify-center rounded-xl border border-border bg-muted/40 p-6">
            <x-ui.apple-dock :magnification="false" />
        </div>
        <p class="text-xs text-muted-foreground">{{ __('Set magnification to false for a calm, fixed-size dock.') }}</p>
    </div>
</div>
```

## Props

- `items` (mixed|null, default `null`) — Array of {label, href, icon} entries to render as dock tiles; defaults to a sample app set.
- `magnification` (bool, default `true`) — Enables the pointer-driven magnify and lift effect on hover.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Building a macOS-style app launcher or navigation dock where icons magnify as the pointer passes over them.
- Presenting a short, fixed row of navigation destinations with a playful, physical feel.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The row needs to scroll, wrap, or hold many items; a dock is sized for a small fixed set of icons. Use scroll-area or cluster for a larger, scrolling or wrapping set.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:apple-dock>` tag (or `<x-ui.apple-dock>`) 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/apple-dock
- Registry JSON (files, props, contract): https://brokui.dev/r/open/apple-dock.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
items mixed | null null Array of {label, href, icon} entries to render as dock tiles; defaults to a sample app set.
magnification bool true Enables the pointer-driven magnify and lift effect on hover.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

dock dock-item dock-label

Behavior

  • Tracks pointer position across the bar and scales the nearest tile up with a falloff curve onto its neighbours, mirroring the macOS Dock.
  • Tile geometry is measured once on init and resize and cached, and pointer updates batch into a single animation frame so hovering never touches layout on the hot path.
  • With magnification off, or under reduced motion, the dock renders static and non-scaling; every tile stays a real link either way.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, seo.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Building a macOS-style app launcher or navigation dock where icons magnify as the pointer passes over them.
  • Presenting a short, fixed row of navigation destinations with a playful, physical feel.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • The row needs to scroll, wrap, or hold many items; a dock is sized for a small fixed set of icons. Use scroll-area or cluster for a larger, scrolling or wrapping set.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root dock dock-item dock-label
Theming hooks
dock dock-item dock-label

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • Each tile is a real <a> with an aria-label and title carrying its name; the floating tooltip label is aria-hidden since the name is already on the control.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="apple-dock-{{ $record->id }}">
    <div class="flex flex-col gap-6">
        {{-- Default magnifying dock --}}
        <div class="flex flex-col gap-2">
            <label class="text-sm font-medium text-foreground">{{ __('Magnifying dock') }}</label>
            <div class="flex min-h-32 items-end justify-center rounded-xl border border-border bg-muted/40 p-6">
                <x-ui.apple-dock />
            </div>
            <p class="text-xs text-muted-foreground">{{ __('Hover across the tiles — the one under the pointer magnifies and its neighbours scale with a falloff.') }}</p>
        </div>
    
        {{-- Magnification disabled (static dock) --}}
        <div class="flex flex-col gap-2">
            <label class="text-sm font-medium text-foreground">{{ __('Static (no magnification)') }}</label>
            <div class="flex min-h-32 items-end justify-center rounded-xl border border-border bg-muted/40 p-6">
                <x-ui.apple-dock :magnification="false" />
            </div>
            <p class="text-xs text-muted-foreground">{{ __('Set magnification to false for a calm, fixed-size dock.') }}</p>
        </div>
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:apple-dock
        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.

resources/views/components/ui/apple-dock.blade.php Blade
@props([
    'items'         => null,
    'magnification' => true,
])

{{--
    Apple Dock — a macOS-style floating, magnifying dock.

    A horizontally centered, frosted bar holding a row of icon tiles. As the
    pointer travels across the bar, the tile nearest the cursor magnifies and
    lifts while its neighbours scale by a smaller amount (a falloff curve), the
    way the macOS Dock does. A tooltip label fades in above the magnified tile.

    Magnification lives in `uiDock()` (apple-dock.js): it tracks pointer x over
    the dock and sets a `scale` per tile from the distance to the cursor. The
    scale is applied via an inline `transform` (a transform is not a colour, so
    it is allowed inline) plus a CSS-var-driven lift — never spliced into an
    arbitrary Tailwind class. Reduced-motion users get a static, non-scaling dock.

    Perf: tile geometry is measured once (init + resize) and cached, and the
    pointermove handler batches its reactive write into a single rAF, so
    `tileStyle()` never touches the DOM on the hot path — see apple-dock.js.

    A11y: each tile is a real <a> with an accessible name (aria-label + title).
    The visual tooltip is aria-hidden (the name is already on the control). All
    user-facing strings pass through __(). Logical padding keeps it RTL-safe;
    the dock is centered so mirroring needs no further work.

    data-slot: dock (root) / dock-item (tile) / dock-label (tooltip).
--}}

@php
    // Generic line-icon paths (Finder / Mail / Calendar / Photos / Music /
    // Settings style). Defined here so default data renders standalone.
    $icons = [
        'finder'   => '<rect x="3" y="3" width="18" height="18" rx="5"/><path d="M8.5 9.5h.01M15.5 9.5h.01"/><path d="M9 15c.9.8 1.9 1.2 3 1.2s2.1-.4 3-1.2"/>',
        'mail'     => '<rect x="2.5" y="5" width="19" height="14" rx="3"/><path d="m3.5 7 7.3 5.1a2 2 0 0 0 2.4 0L20.5 7"/>',
        'calendar' => '<rect x="3.5" y="4.5" width="17" height="16" rx="3"/><path d="M3.5 9.5h17M8 2.5v4M16 2.5v4"/><path d="M7.5 13.5h.01M12 13.5h.01M16.5 13.5h.01M7.5 17h.01M12 17h.01"/>',
        'photos'   => '<rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="9" cy="9" r="2"/><path d="m4 18 5-4.5a2 2 0 0 1 2.7.1L20 21"/>',
        'music'    => '<path d="M9 18V6l11-2v10"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="14" r="3"/>',
        'settings' => '<circle cx="12" cy="12" r="3.2"/><path d="M12 2v2.5M12 19.5V22M4.2 4.2l1.8 1.8M18 18l1.8 1.8M2 12h2.5M19.5 12H22M4.2 19.8 6 18M18 6l1.8-1.8"/>',
    ];

    $items ??= [
        ['label' => __('Finder'),   'href' => '#', 'icon' => 'finder'],
        ['label' => __('Mail'),     'href' => '#', 'icon' => 'mail'],
        ['label' => __('Calendar'), 'href' => '#', 'icon' => 'calendar'],
        ['label' => __('Photos'),   'href' => '#', 'icon' => 'photos'],
        ['label' => __('Music'),    'href' => '#', 'icon' => 'music'],
        ['label' => __('Settings'), 'href' => '#', 'icon' => 'settings'],
    ];
@endphp

<nav
    x-data="uiDock({ magnify: @js((bool) $magnification) })"
    x-on:pointermove="onMove($event)"
    x-on:pointerleave="reset()"
    data-slot="dock"
    aria-label="{{ __('Application dock') }}"
    {{ $attributes->merge(['class' => 'mx-auto flex h-16 w-fit items-end gap-2 rounded-md border border-border bg-card/80 p-2 shadow-lg backdrop-blur-md supports-[backdrop-filter]:bg-card/70']) }}
>
    @foreach ($items as $i => $item)
        @php
            $label = $item['label'] ?? '';
            $href  = $item['href'] ?? '#';
            $key   = $item['icon'] ?? 'finder';
            $path  = $icons[$key] ?? $icons['finder'];
        @endphp

        <a
            href="{{ $href }}"
            data-slot="dock-item"
            x-ref="item"
            data-index="{{ $i }}"
            aria-label="{{ $label }}"
            title="{{ $label }}"
            class="group/dock relative flex aspect-square size-12 origin-bottom items-center justify-center rounded-md border border-border/60 bg-muted text-muted-foreground transition-[color,background-color] duration-150 ease-out will-change-transform hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card motion-reduce:transition-none"
            x-bind:style="tileStyle({{ $i }})"
        >
            <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="1.75"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="size-1/2"
                aria-hidden="true"
            >
                {!! $path !!}
            </svg>

            {{-- Tooltip label — purely visual; the accessible name is on the link. --}}
            <span
                data-slot="dock-label"
                aria-hidden="true"
                class="pointer-events-none absolute -top-2 left-1/2 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground opacity-0 shadow-md transition-opacity duration-150 ease-out group-hover/dock:opacity-100 group-focus-visible/dock:opacity-100 motion-reduce:transition-none"
            >
                {{ $label }}
            </span>
        </a>
    @endforeach
</nav>
resources/js/ui/apple-dock.js JS
/**
 * Apple Dock behaviour — macOS-style pointer magnification.
 *
 * Tracks the pointer's x position over the dock and, for every tile, computes a
 * scale from the horizontal distance between the pointer and that tile's centre.
 * The tile under the cursor reaches `maxScale` (~1.6); each step away falls off
 * smoothly via a cosine ramp, so the immediate neighbours land near ~1.3 / ~1.15
 * and distant tiles return to 1.0 — the classic Dock magnification curve. The
 * scale is exposed through `tileStyle(i)` which the Blade binds as an inline
 * `transform` plus a `--dock-lift` custom property (a transform/length, never a
 * colour).
 *
 * Performance: each tile's centre + width is measured ONCE (on init, and again
 * on resize) and cached in `_tiles`, so `tileStyle()`/`magnitude()` are pure
 * arithmetic against cached numbers — no `querySelector`/`getBoundingClientRect`
 * runs inside the pointermove path. The native `pointermove` handler itself only
 * records the latest x; the reactive `pointerX` write (which fans out to every
 * tile's `x-bind:style`) is batched into a single rAF, so a fast mousemove burst
 * still costs at most one measurement pass and one style recompute per frame,
 * matching the rAF-batching pattern used by `particles`/`starfield`/
 * `draggable-card-stack` in this registry.
 *
 * Magnification (and the resize listeners that back it) is skipped entirely when
 * the user prefers reduced motion or the `magnify` prop is false, leaving a
 * static, accessible dock. The pending rAF and the resize listeners are torn
 * down in `destroy()`.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiDock', (config = {}) => ({
        magnify: config.magnify ?? true,
        pointerX: null,
        reducedMotion: false,

        // Magnification tuning. `radius` is how far (in tile widths) the effect
        // reaches; `maxScale` is the peak scale directly under the cursor.
        radius: config.radius ?? 2.6,
        maxScale: config.maxScale ?? 0.6, // added on top of 1.0 → peak ~1.6
        maxLift: config.maxLift ?? 10, // px the peak tile rises

        // Cached per-tile geometry ({ centerOffset, width }, in DOM/index order),
        // refreshed only by measure() — never read from inside tileStyle.
        // `centerOffset` is measured from the dock's own left edge, so it stays
        // valid across page scroll; only the dock's own left edge is re-read,
        // once per animation frame, in the pointermove flush below.
        _tiles: [],
        baseSize: 48,
        _rootLeft: 0,
        _pendingX: null,
        _raf: null,
        _measureRaf: null,
        _ro: null,
        _onResize: null,

        init() {
            this.reducedMotion =
                window.matchMedia &&
                window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            if (!this.active) {
                // Static dock: no geometry to cache, nothing to listen for.
                return;
            }

            this.$nextTick(() => this.measure());

            this._onResize = () => this.scheduleMeasure();
            window.addEventListener('resize', this._onResize, { passive: true });

            if (typeof ResizeObserver !== 'undefined') {
                this._ro = new ResizeObserver(() => this.scheduleMeasure());
                this._ro.observe(this.$root);
            }
        },

        destroy() {
            if (this._raf !== null) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
            if (this._measureRaf !== null) {
                cancelAnimationFrame(this._measureRaf);
                this._measureRaf = null;
            }
            if (this._ro) {
                this._ro.disconnect();
                this._ro = null;
            }
            if (this._onResize) {
                window.removeEventListener('resize', this._onResize);
                this._onResize = null;
            }
        },

        /** Whether magnification should run at all. */
        get active() {
            return this.magnify && !this.reducedMotion;
        },

        /**
         * Re-measure every tile's centre x + width in one pass. Called on init
         * and on resize only, so the hot pointermove path never touches the DOM.
         */
        measure() {
            // Only ever measured at rest: the tiles' resting centres are the
            // reference the magnification curve is computed against.
            if (this.pointerX !== null) return;
            const root = this.$root.getBoundingClientRect();
            this._rootLeft = root.left;

            const tiles = this.$root.querySelectorAll('[data-slot="dock-item"]');
            this._tiles = Array.from(tiles).map((el) => {
                const rect = el.getBoundingClientRect();
                return {
                    // Relative to the dock, not the viewport: scrolling the page
                    // moves the dock and the cursor together, so an offset stays
                    // correct where a cached viewport x would silently go stale.
                    centerOffset: rect.left - root.left + rect.width / 2,
                    width: rect.width || 48,
                };
            });
            this.baseSize = this._tiles[0]?.width || 48;
        },

        /** Coalesce bursts of resize/ResizeObserver callbacks into one measure(). */
        scheduleMeasure() {
            if (this._measureRaf !== null) return;
            this._measureRaf = requestAnimationFrame(() => {
                this._measureRaf = null;
                this.measure();
            });
        },

        /**
         * Record the pointer's x on each native move, batched into a single rAF
         * so the reactive `pointerX` write (and the `x-bind:style` recompute it
         * fans out to every tile) happens at most once per frame regardless of
         * how many pointermove events fire in between.
         */
        onMove(e) {
            if (!this.active) return;
            this._pendingX = e.clientX;
            if (this._raf !== null) return;
            this._raf = requestAnimationFrame(() => {
                this._raf = null;
                // One layout read per frame for the whole dock, instead of one
                // per tile per event. Re-read here so scrolling the page while
                // the pointer tracks cannot desync the cached tile offsets.
                this._rootLeft = this.$root.getBoundingClientRect().left;
                this.pointerX = this._pendingX;
            });
        },

        /** Drop magnification when the pointer leaves the dock. */
        reset() {
            if (this._raf !== null) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
            this.pointerX = null;
        },

        /**
         * Falloff for a single tile given its centre x and width.
         * Returns { scale, lift } where scale is the multiplier (≥1) and lift is
         * a positive pixel offset (applied as a negative translateY).
         */
        magnitude(centerX, width) {
            if (!this.active || this.pointerX === null) {
                return { scale: 1, lift: 0 };
            }

            const dist = Math.abs(this.pointerX - centerX) / (width || 48);

            if (dist >= this.radius) {
                return { scale: 1, lift: 0 };
            }

            // Cosine ramp: 1 at the cursor, easing to 0 at the radius edge.
            const t = Math.cos((dist / this.radius) * (Math.PI / 2));
            return {
                scale: 1 + this.maxScale * t,
                lift: this.maxLift * t,
            };
        },

        /**
         * Inline style for tile `index`, computed purely from the cached
         * geometry in `_tiles` — no DOM reads happen here.
         */
        tileStyle(index) {
            // The tile's box grows (width and height), so neighbours are pushed
            // apart the way the macOS Dock spreads — a transform scale would
            // overlap them. At rest the size eases back; while the pointer is
            // tracking, no transition runs so magnification stays glued to the
            // cursor instead of trailing it by the duration.
            const base = this.baseSize || 48;
            const rest = {
                width: base + 'px',
                height: base + 'px',
                transform: 'translateY(0)',
                '--dock-lift': '0px',
                transition: 'width 160ms var(--ease-standard), height 160ms var(--ease-standard), transform 160ms var(--ease-standard)',
            };

            if (!this.active || this.pointerX === null) {
                return rest;
            }

            const tile = this._tiles[index];
            if (!tile) {
                return rest;
            }

            const { scale, lift } = this.magnitude(this._rootLeft + tile.centerOffset, base);
            const size = Math.round(base * scale);

            return {
                width: size + 'px',
                height: size + 'px',
                transform: `translateY(${-lift}px)`,
                '--dock-lift': lift + 'px',
                transition: 'none',
            };
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.2
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