# Dock (Moonarc)

A macOS-style dock: the tile under the pointer swells, its neighbours swell less, and the row pushes apart to make room. Zero JavaScript: :has() sets one size number on the hovered tile and the four around it, and the tiles transition their size on the preset curve. Keyboard focus magnifies the same way; touch gets a plain row of links.

- Import: `import Dock from '@moonarc/core/Dock'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/dock.json`
- Tier A · category ui · trigger hover
- Readout: `<Dock magnify={1.6}>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4); elsewhere: without :has() the tiles keep their size and the labels stay hidden
- Measured cost: 0 B JS (CSS 6.8 kB raw)
- Page: https://moonarc.dev/components/dock/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `{ href: string; label: string; icon?: string; current?: boolean }[]` | none | The tiles; icon is inline SVG (or any HTML), default the first letter of the label. |
| `size` | `number` | `44` | Tile size in px. |
| `magnify` | `number` | `1.6` | Growth of the hovered tile as a multiple of size; the neighbours get 60 % and 25 % of the growth. |
| `fixed` | `boolean` | `false` | Pin to the bottom centre of the viewport. |
| `label` | `string` | `'Dock'` | Accessible name of the nav. |

## Usage

```astro
<Dock
  items={[
    { href: '/', label: 'Home', icon: homeSvg, current: true },
    { href: '/work/', label: 'Work', icon: workSvg },
    { href: '/writing/', label: 'Writing', icon: penSvg },
    { href: 'mailto:hi@example.com', label: 'Email', icon: mailSvg },
  ]}
  fixed
/>
```

## Reduced motion

No magnification (the size number stays 1) and the labels appear without travel.

## With ClientRouter

CSS-only; nothing to rebind. Mark the current page with the item's current flag on each page.

## Craft

- The dock magnifies by neighbourhood, not by pointer distance: the hovered tile, ±1 and ±2, in three steps. A cursor-distance curve needs a script; this reads as a dock at a glance and costs nothing.
- Width and height transition, a deliberate layout change: the point of a dock is that the tiles push each other apart. It is five small elements on a hover, on the preset curve, well inside a frame.
- The row has a fixed height and the tiles align to its bottom, so growth goes up and the dock's own box never jumps.
- Labels are aria-hidden tooltips; the link's aria-label carries the name, so a screen reader hears "Work, link" without a hover.
- Everything hover-related sits under @media (hover: hover): on a phone the dock is a row of links, which is what a phone wants.

## Replaces

- Dock (Magic UI)
- Dock (React Bits)
- FloatingDock (Aceternity)
- Dock (Motion Primitives)

## Source

```astro
---
/**
 * Dock — a row of icons that swell under the pointer, the neighbours with
 * them, zero JS.
 *
 * Every item's size is one custom property; :has() sets it on the hovered
 * (or keyboard-focused) item, on the two beside it and the two beyond, in
 * three steps from the `magnify` prop, and the width and height transition
 * on the preset curve — so the tiles push each other apart the way a real
 * dock does rather than scaling over one another. The row is a fixed
 * height and the growth goes up. Labels are tooltips above the item, no
 * popover needed. Touch gets a plain row of links.
 */
import type { HTMLAttributes } from 'astro/types';

interface Item {
  href: string;
  label: string;
  /** Inline SVG (or any HTML) for the tile; default: the label's first letter. */
  icon?: string;
  /** Marks the current page (aria-current + a dot). */
  current?: boolean;
}

interface Props extends HTMLAttributes<'nav'> {
  items: Item[];
  /** Tile size in px. */
  size?: number;
  /** Growth of the hovered tile, as a multiple of size; the neighbours get 60 % and 25 % of the growth. */
  magnify?: number;
  /** Pin to the bottom centre of the viewport. */
  fixed?: boolean;
  /** Accessible name of the nav. */
  label?: string;
}

const { items, size = 44, magnify = 1.6, fixed = false, label = 'Dock', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-dock-size:${size}px`, `--ma-dock-magnify:${magnify}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const glyph = (l: string) => `<b>${l.trim().charAt(0).toUpperCase()}</b>`;
---

<nav class:list={['ma-dock', className]} data-fixed={fixed ? '' : undefined} aria-label={label} style={inline} {...rest}>
  <ul class="ma-dock__list">
    {
      items.map((it) => (
        <li class="ma-dock__item">
          <a href={it.href} class="ma-dock__link" aria-label={it.label} aria-current={it.current ? 'page' : undefined}>
            <span class="ma-dock__icon" aria-hidden="true" set:html={it.icon ?? glyph(it.label)} />
            <span class="ma-dock__label" aria-hidden="true">{it.label}</span>
          </a>
        </li>
      ))
    }
  </ul>
</nav>

<style is:global>
  @layer components {
    :where(.ma-dock) {
      /* the magnification the rules read; reduced motion sets it to 1 */
      --ma-dock-m: var(--ma-dock-magnify, 1.6);
      display: inline-flex;
      padding: 0.375rem;
      border: 1px solid var(--ma-edge);
      border-radius: 1.25rem;
      background: var(--ma-panel);
      backdrop-filter: blur(16px);
      box-shadow: 0 16px 40px -20px rgb(0 0 0 / 0.45);
    }
    :where(.ma-dock[data-fixed]) {
      position: fixed;
      bottom: 1rem;
      left: 50%;
      z-index: 40;
      translate: -50% 0;
    }
    :where(.ma-dock__list) {
      display: flex;
      align-items: flex-end;
      gap: 0.375rem;
      height: var(--ma-dock-size, 44px);
      margin: 0;
      padding: 0;
      list-style: none;
    }
    /* every tile's size is one number; the hover rules below set it */
    :where(.ma-dock__item) {
      --ma-dock-k: 1;
      position: relative;
      flex: none;
      width: calc(var(--ma-dock-size, 44px) * var(--ma-dock-k));
      height: calc(var(--ma-dock-size, 44px) * var(--ma-dock-k));
      transition:
        width var(--ma-duration) var(--ma-ease),
        height var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-dock__link) {
      position: relative;
      display: grid;
      place-items: center;
      width: 100%;
      height: 100%;
      border: 1px solid var(--ma-edge);
      border-radius: 28%;
      background: color-mix(in srgb, var(--ma-ink) 6%, transparent);
      color: inherit;
      text-decoration: none;
      transition: background-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-dock__link:hover) {
      background: color-mix(in srgb, var(--ma-ink) 11%, transparent);
    }
    :where(.ma-dock__link:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    :where(.ma-dock__link[aria-current='page'])::after {
      content: '';
      position: absolute;
      bottom: -0.35rem;
      left: 50%;
      width: 4px;
      height: 4px;
      border-radius: 999px;
      background: currentColor;
      translate: -50% 0;
    }
    :where(.ma-dock__icon) {
      display: grid;
      place-items: center;
      width: 56%;
      height: 56%;
      font-weight: 600;
      font-size: calc(var(--ma-dock-size, 44px) * 0.38 * var(--ma-dock-k));
      line-height: 1;
    }
    :where(.ma-dock__icon svg) {
      width: 100%;
      height: 100%;
    }
    /* the label: a tooltip above, arriving from a few px below on the hover travel */
    :where(.ma-dock__label) {
      position: absolute;
      bottom: calc(100% + 0.5rem);
      left: 50%;
      padding: 0.3em 0.65em;
      border-radius: 0.375rem;
      background: var(--ma-ink);
      color: var(--ma-panel);
      font-size: 0.75rem;
      font-weight: 500;
      line-height: 1.2;
      white-space: nowrap;
      opacity: 0;
      translate: -50% var(--ma-travel-hover, 4px);
      pointer-events: none;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        translate var(--ma-duration) var(--ma-ease);
    }
    @media (hover: hover) {
      /* the hovered tile, its two neighbours, and the two beyond — three sizes from one number */
      :where(.ma-dock__item:is(:hover, :has(:focus-visible))) {
        --ma-dock-k: var(--ma-dock-m);
      }
      :where(.ma-dock__item:has(+ .ma-dock__item:is(:hover, :has(:focus-visible)))),
      :where(.ma-dock__item:is(:hover, :has(:focus-visible)) + .ma-dock__item) {
        --ma-dock-k: calc(1 + (var(--ma-dock-m) - 1) * 0.6);
      }
      :where(.ma-dock__item:has(+ .ma-dock__item + .ma-dock__item:is(:hover, :has(:focus-visible)))),
      :where(.ma-dock__item:is(:hover, :has(:focus-visible)) + .ma-dock__item + .ma-dock__item) {
        --ma-dock-k: calc(1 + (var(--ma-dock-m) - 1) * 0.25);
      }
      :where(.ma-dock__item:is(:hover, :has(:focus-visible)) .ma-dock__label) {
        opacity: 1;
        translate: -50% 0;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-dock) {
        --ma-dock-m: 1;
      }
      :where(.ma-dock__item),
      :where(.ma-dock__label) {
        transition: none;
        translate: -50% 0;
      }
      :where(.ma-dock__item) {
        translate: none;
      }
    }
  }
</style>

```
