Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/dock.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
dock.tsx. It's yours: change anything.
Usage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ({ label; icon; href?; onClick?; active? } | null)[] | — | Each item renders a link with href, otherwise a button. null draws a divider. |
| label | string | "Dock" | Accessible name of the navigation. |
| size | number | 44 | Resting size in pixels. |
| magnification | number | 2 | Size under the pointer, as a multiple of size. |
| distance | number | 150 | How far the swell reaches, in pixels. |
| bounce | boolean | true | Hop 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
- v1.0.0Latest
First release.