Navigation

Dock

A macOS-style dock — icon tiles magnify as the cursor approaches, with spring physics and pure-CSS label bubbles.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"

/** Horizontal distance (px) at which magnification fully decays back to baseSize. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dock.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Dock" component using the motion
package (import from "motion/react").

Contract
- export function Dock({ items, magnification = 64, baseSize = 40, className,
  ...rest }) — rest props spread onto the root div, className merged via cn().
- items: { id: string; label: string; icon: React.ReactNode;
  onClick?: () => void; href?: string }[]. An item with href renders a real
  <a>; otherwise a <button type="button">. Both call onClick.
- magnification = peak tile size (px) directly under the cursor;
  baseSize = resting tile size (px).

Behavior
- The container owns one useMotionValue(Infinity) mouseX: mousemove writes
  e.clientX, mouseleave resets to Infinity. MotionValues bypass React state,
  so tracking never re-renders the tree.
- Each item is its own subcomponent (hooks cannot live in a map callback).
  It measures its center via ref + getBoundingClientRect inside a
  useTransform, derives distance = mouseX - centerX, then maps
  [-150, 0, 150] -> [baseSize, magnification, baseSize] (useTransform clamps
  outside the range, so Infinity collapses every tile to baseSize).
- A useSpring (mass 0.1, stiffness 170, damping 14) smooths the size; tile
  width and height both bind to it, and an inner icon layer binds to size/2.
- Keyboard parity: on focus the tile writes its own center into the shared
  mouseX so the focused tile (and neighbors) magnify exactly like hover;
  blur resets to Infinity. No separate focus animation code path.
- Label bubble floats above the tile on hover and :focus-visible with pure
  CSS (group-hover / group-focus-visible opacity + translate) — no portal,
  no radix. The tile is position:relative, so no positioning math is needed.
- prefers-reduced-motion (motion's useReducedMotion): bypass the transform
  pipeline entirely — fixed baseSize tiles, mouse tracking not attached;
  clicks, focus rings and label bubbles keep working.

Rendering & styling
- Bar: flex items-end gap-2 rounded-2xl border bg-background/60 px-2 pb-2
  pt-1 backdrop-blur, role="toolbar" with an aria-label.
- Tile: rounded-xl bg-muted hover:bg-accent transition-colors, icon centered;
  focus-visible:ring-2 ring-ring. Icon and bubble layers are aria-hidden;
  the accessible name is aria-label={label} on the button/anchor itself.
- Bubble: bg-popover text-popover-foreground border shadow-sm text-xs,
  pointer-events-none, whitespace-nowrap.
- Semantic tokens only — no hex/rgb/oklch; merge consumer className via cn().
- The bar grows upward while magnifying; in layouts where reflow matters,
  place it in a fixed-height items-end wrapper sized to magnification.

Customization levers
- Falloff curve: the [-150, 0, 150] input range is the feel knob — widen to
  ~200 for a softer, wavier ripple across neighbors, narrow to ~100 so only
  the hovered tile reacts. Add intermediate stops (e.g. [-150, -75, 0, ...])
  for a non-linear decay.
- Spring feel: stiffness 170 / damping 14 is snappy; lower stiffness (~120)
  reads lazier, higher damping kills overshoot.
- Size scale: baseSize/magnification are plain px props — 48/80 for a
  hero-sized dock, 32/48 for a compact toolbar; icon layer stays at size/2.
- Vertical dock: swap the axis — track clientY, measure bounds.y + height/2,
  container flex-col items-end on a screen edge, bubble to the tile's side.
- Separators and running indicators: insert a self-stretching 1px border-l
  divider between item groups, and an absolutely-positioned size-1 rounded
  dot (bg-foreground/60) under active items driven by an activeIds prop.
- Surface: swap bg-background/60 + backdrop-blur for a solid bg-card if the
  dock never overlays content; tiles can go bg-transparent hover:bg-muted
  for a flatter look.

Concepts

  • Proximity magnification — every tile derives its size from its horizontal distance to one shared cursor value, so neighbors swell in a smooth falloff instead of a single tile popping on hover.
  • MotionValue pipeline — mouseX → useTransform → useSpring writes styles outside the React render cycle; a 60fps mousemove stream causes zero re-renders of the item tree.
  • Focus parity — keyboard focus feeds the focused tile's own center into the same mouseX value, so focus magnifies through the identical spring path as hover: one code path, no special-cased animation.
  • Infinity as "no cursor" — resetting mouseX to Infinity makes every distance clamp to base size, so mouse-leave settle is the same spring relaxing, not a separate exit animation.
  • Pure-CSS label bubble — labels reveal on group-hover / group-focus-visible with an opacity + translate transition; the tile itself is the positioning context, so no portal or floating-ui is needed.
  • Reduced-motion fallback — under prefers-reduced-motion the transform pipeline is bypassed rather than slowed: tiles hold base size while clicks, focus rings and labels keep full function.

On This Page