Skip to content

Components / UI

Dock

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.

Live demo

hover · the neighbours follow

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 105 · Firefox 121 · Safari 15.4; needs has. Elsewhere: without :has() the tiles keep their size and the labels stay hidden.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Dock from '@moonarc/core/Dock';
---
<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
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/dock.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Dock.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>

Props

PropTypeDefaultDescription
items{ href: string; label: string; icon?: string; current?: boolean }[]noneThe tiles; icon is inline SVG (or any HTML), default the first letter of the label.
sizenumber44Tile size in px.
magnifynumber1.6Growth of the hovered tile as a multiple of size; the neighbours get 60 % and 25 % of the growth.
fixedbooleanfalsePin to the bottom centre of the viewport.
labelstring'Dock'Accessible name of the nav.

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.

Why it is built this way

Replaces: Dock (Magic UI) · Dock (React Bits) · FloatingDock (Aceternity) · Dock (Motion Primitives). See the migration table.

Magnetic

Pointer

A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves.

every browserpointerhover

click · the pill slides on a view transition

A floating pill of links whose active pill glides to the page you chose.

newly · Chrome 125 · Firefox 147 · Safari 26clickhover

Progressive Blur

Backgrounds

An edge that blurs what scrolls under it, more the closer it gets: a few bands, each with a backdrop blur twice the last and a mask that keeps it to its own slice, so the blur ramps instead of stepping.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown