Skip to content
Uxelith
Sign in
Freev1.0.0Navigation

Dock

A macOS-style dock: icons swell up to 2× under the pointer or keyboard focus, labels spring in, and apps hop when launched.

pnpm dlx shadcn add …/r/dock.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/dock.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as dock.tsx. It's yours: change anything.

Usage

example.tsx
import { Dock } from "@/components/dock"

const icon = (path: string) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
    <path d={path} />
  </svg>
)

export function AppDock() {
  return (
    <Dock
      label="Apps"
      items={[
        { label: "Home", icon: icon("M3 11.5 12 4l9 7.5M5.5 9.5V20h13V9.5"), href: "/", active: true },
        { label: "Search", icon: icon("M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM20 20l-3.5-3.5"), href: "/search" },
      ]}
    />
  )
}

Source

One file, 228 lines, nothing hidden. Read it before you install it.

dock.tsx
"use client";

import {
  AnimatePresence,
  type MotionValue,
  motion,
  useAnimate,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface DockItem {
  label: string;
  icon: React.ReactNode;
  /** Renders a link; otherwise a button using `onClick`. */
  href?: string;
  onClick?: () => void;
  /** Marks the current page or app with a dot and `aria-current`. */
  active?: boolean;
}

export interface DockProps extends React.ComponentProps<"nav"> {
  /** Items in order. A `null` entry draws a divider between groups. */
  items: (DockItem | null)[];
  /** Accessible name of the navigation, e.g. "Apps". */
  label?: string;
  /** Resting size of each item, in pixels. */
  size?: number;
  /** Size of the item right under the pointer, as a multiple of `size`. */
  magnification?: number;
  /** How far, in pixels, the magnification reaches either side of the pointer. */
  distance?: number;
  /** Hop the icon when it is clicked, like an app launching. */
  bounce?: boolean;
}

// Motion presets (ADR-010).
const FOLLOW = { mass: 0.1, stiffness: 150, damping: 12 };
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;

function DockButton({
  item,
  pointerX,
  size,
  magnification,
  distance,
  bounce,
}: {
  item: DockItem;
  pointerX: MotionValue<number>;
  size: number;
  magnification: number;
  distance: number;
  bounce: boolean;
}) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [scope, animate] = useAnimate<HTMLSpanElement>();
  const [labelShown, setLabelShown] = React.useState(false);
  const reduceMotion = useReducedMotion();

  const offset = useTransform(pointerX, (x) => {
    const rect = ref.current?.getBoundingClientRect();
    return rect ? x - (rect.left + rect.width / 2) : Infinity;
  });
  // A cosine falloff reads as one smooth swell rather than a tent.
  const target = useTransform(offset, (d) => {
    if (!Number.isFinite(d) || Math.abs(d) >= distance) return size;
    const t = (Math.cos((d / distance) * Math.PI) + 1) / 2;
    return size + (size * magnification - size) * t;
  });
  const width = useSpring(target, FOLLOW);
  const iconSize = useTransform(width, (w) => w * 0.46);

  const launch = () => {
    if (bounce && !reduceMotion && scope.current) {
      void animate(scope.current, { y: [0, -size * 0.32, 0] }, { duration: 0.5, ease: [0.22, 1, 0.36, 1] });
    }
    item.onClick?.();
  };

  const className = cn(
    "flex size-full items-center justify-center rounded-[28%] border border-border bg-card text-foreground shadow-sm outline-none",
    "bg-gradient-to-b from-foreground/[0.04] to-transparent transition-colors duration-150 hover:bg-accent",
    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
  );
  const content = (
    <>
      <motion.span
        aria-hidden="true"
        style={reduceMotion ? { width: size * 0.46, height: size * 0.46 } : { width: iconSize, height: iconSize }}
        className="flex [&_svg]:size-full"
      >
        {item.icon}
      </motion.span>
      <span className="sr-only">{item.label}</span>
    </>
  );
  const show = {
    onPointerEnter: () => setLabelShown(true),
    onPointerLeave: () => setLabelShown(false),
    onFocus: (e: React.FocusEvent<HTMLElement>) => setLabelShown(e.currentTarget.matches(":focus-visible")),
    onBlur: () => setLabelShown(false),
  };

  return (
    <motion.div
      ref={ref}
      // Size, not scale: neighbours have to make room, as in the macOS Dock.
      style={reduceMotion ? { width: size, height: size } : { width, height: width }}
      className="relative flex shrink-0 flex-col items-center"
    >
      <AnimatePresence>
        {labelShown && (
          <motion.span
            aria-hidden="true"
            initial={{ opacity: 0, y: 6, scale: 0.94 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 2, scale: 0.98 }}
            transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
            className="pointer-events-none absolute -top-10 origin-bottom whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md"
          >
            {item.label}
          </motion.span>
        )}
      </AnimatePresence>
      <span ref={scope} className="flex size-full">
        {item.href ? (
          <a href={item.href} aria-current={item.active ? "page" : undefined} onClick={launch} className={className} {...show}>
            {content}
          </a>
        ) : (
          <button type="button" onClick={launch} aria-pressed={item.active || undefined} className={className} {...show}>
            {content}
          </button>
        )}
      </span>
      <AnimatePresence>
        {item.active && (
          <motion.span
            aria-hidden="true"
            initial={{ opacity: 0, scale: 0 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0 }}
            transition={reduceMotion ? { duration: 0 } : SPRING_BOUNCY}
            className="absolute -bottom-2 size-1 rounded-full bg-foreground"
          />
        )}
      </AnimatePresence>
    </motion.div>
  );
}

/**
 * A row of app icons that swell under the pointer, like the macOS Dock.
 *
 * - A real `<nav>` of links (or buttons); each has a text label for screen
 *   readers and a label above it on hover and keyboard focus.
 * - Keyboard focus swells the focused icon too, so the dock is as alive
 *   for keyboard users as for the mouse.
 * - The current item is marked with `aria-current="page"` and a dot.
 * - No magnification or hop under `prefers-reduced-motion`; on touch screens
 *   the items stay their resting size.
 */
export function Dock({
  items,
  label = "Dock",
  size = 44,
  magnification = 2,
  distance = 150,
  bounce = true,
  className,
  ...props
}: DockProps) {
  const pointerX = useMotionValue(Infinity);
  const pointerInside = React.useRef(false);

  const centreOn = (el: Element) => {
    const rect = el.getBoundingClientRect();
    pointerX.set(rect.left + rect.width / 2);
  };

  return (
    <nav aria-label={label} className={cn("flex justify-center", className)} {...props}>
      <ul
        onPointerMove={(e) => {
          if (e.pointerType === "touch") return;
          pointerInside.current = true;
          pointerX.set(e.clientX);
        }}
        onPointerLeave={() => {
          pointerInside.current = false;
          pointerX.set(Infinity);
        }}
        onFocus={(e) => {
          if (!pointerInside.current && e.target.matches(":focus-visible")) centreOn(e.target);
        }}
        onBlur={(e) => {
          if (!pointerInside.current && !e.currentTarget.contains(e.relatedTarget)) pointerX.set(Infinity);
        }}
        className="flex items-end gap-2.5 rounded-2xl border border-border bg-background/70 px-2.5 pb-2.5 pt-2.5 shadow-[0_8px_32px_-8px_rgb(0_0_0/0.35)] backdrop-blur-xl"
        style={{ height: size + 20 }}
      >
        {items.map((item, i) =>
          item ? (
            <li key={item.label} className="flex">
              <DockButton
                item={item}
                pointerX={pointerX}
                size={size}
                magnification={magnification}
                distance={distance}
                bounce={bounce}
              />
            </li>
          ) : (
            <li key={`divider-${i}`} aria-hidden="true" className="mx-0.5 w-px self-stretch bg-border" />
          ),
        )}
      </ul>
    </nav>
  );
}

Props

PropTypeDefaultDescription
items({ label; icon; href?; onClick?; active? } | null)[]—Each item renders a link with href, otherwise a button. null draws a divider.
labelstring"Dock"Accessible name of the navigation.
sizenumber44Resting size in pixels.
magnificationnumber2Size under the pointer, as a multiple of size.
distancenumber150How far the swell reaches, in pixels.
bouncebooleantrueHop the icon when it is clicked.

Accessibility

  • A <nav> of real links or buttons, each with a text label for screen readers.
  • Labels appear on hover and on keyboard focus; keyboard focus swells the focused icon too.
  • The current item has aria-current="page" (links) or aria-pressed (buttons) plus a visible dot.
  • Dividers are hidden from assistive tech.
  • No magnification or hop under prefers-reduced-motion or on touch.

Changelog

  1. v1.0.0Latest

    First release.

More in Navigation

All 6 in Navigation
  • NavigationFree

    Accordion

    Questions that spring open to their answers — reversible mid-way — with a hover highlight that glides between titles and a plus that folds into a minus (or a chevron). Single or multiple open, two looks.

  • NavigationFree

    Animated Tabs

    Tabs with an indicator that springs to the selected tab, a hover highlight that follows the pointer, and panels that slide in from the side you're heading to while the height eases to fit. Pill or underline.

  • NavigationPro

    Command Palette

    A ⌘K command menu with ranked fuzzy search and match highlighting. Give it a trigger and the search button grows into the palette and shrinks back on close. Inline or in a dialog.