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.
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.
<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
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:
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.
-
resources/views/components/ui/apple-dock.blade.php -
resources/js/ui/apple-dock.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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>
/**
* 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