Bottom app dock whose icons magnify with distance falloff on pointer proximity — transform-only springs, plain taps on touch.
npx shadcn@latest add @paragon/dock"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useSpring,
useTransform,
useReducedMotion,
type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";
interface DockContextValue {
mouseX: MotionValue<number>;
magnification: number;
distance: number;
enabled: boolean;
}
const DockContext = React.createContext<DockContextValue | null>(null);
function useDock() {
const context = React.useContext(DockContext);
if (!context) {
throw new Error("DockItem must be used within <Dock>");
}
return context;
}
export interface DockProps extends React.ComponentProps<"div"> {
/** Peak scale of the item directly under the pointer. */
magnification?: number;
/** Pixel radius of the magnification falloff around the pointer. */
distance?: number;
/** Disables pointer magnification entirely. */
static?: boolean;
}
/**
* Bottom app dock. Items magnify with distance falloff as the pointer
* approaches — transform-only scaling driven by motion springs, so layout
* never shifts. Magnification is gated to fine pointers (touch gets plain
* taps) and disabled under reduced motion. Arrow keys move focus between
* items; tooltips appear above on hover and keyboard focus.
*/
export function Dock({
magnification = 1.5,
distance = 140,
static: isStatic = false,
className,
children,
...props
}: DockProps) {
const mouseX = useMotionValue(Infinity);
const reducedMotion = useReducedMotion();
const [finePointer, setFinePointer] = React.useState(false);
React.useEffect(() => {
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
setFinePointer(query.matches);
const onChange = (event: MediaQueryListEvent) =>
setFinePointer(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const enabled = finePointer && !reducedMotion && !isStatic;
const contextValue = React.useMemo(
() => ({ mouseX, magnification, distance, enabled }),
[mouseX, magnification, distance, enabled],
);
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
const buttons = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
);
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (index === -1) return;
event.preventDefault();
const step = event.key === "ArrowRight" ? 1 : -1;
buttons[(index + step + buttons.length) % buttons.length]?.focus();
};
return (
<div
role="toolbar"
aria-orientation="horizontal"
data-slot="dock"
onMouseMove={(event) => enabled && mouseX.set(event.clientX)}
onMouseLeave={() => mouseX.set(Infinity)}
onKeyDown={onKeyDown}
className={cn(
"flex w-fit items-end gap-2 rounded-[20px] bg-card/80 p-2 shadow-border backdrop-blur-md",
className,
)}
{...props}
>
<DockContext.Provider value={contextValue}>
{children}
</DockContext.Provider>
</div>
);
}
export interface DockItemProps
extends Omit<
React.ComponentProps<"button">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
> {
/** Accessible name, shown in the tooltip above the item. */
label: string;
/** Marks the item with a small running-indicator dot. */
active?: boolean;
}
export function DockItem({
label,
active = false,
className,
children,
...props
}: DockItemProps) {
const { mouseX, magnification, distance, enabled } = useDock();
const ref = React.useRef<HTMLButtonElement>(null);
const distanceFromPointer = useTransform(mouseX, (x) => {
const bounds = ref.current?.getBoundingClientRect();
if (!bounds) return Infinity;
return x - bounds.x - bounds.width / 2;
});
const targetScale = useTransform(
distanceFromPointer,
[-distance, 0, distance],
[1, magnification, 1],
);
const scale = useSpring(targetScale, {
mass: 0.1,
stiffness: 180,
damping: 14,
});
return (
<div className="group relative">
<motion.button
ref={ref}
type="button"
aria-label={label}
style={{ scale: enabled ? scale : 1 }}
className={cn(
"pressable flex size-10 origin-bottom items-center justify-center rounded-xl bg-card text-foreground/80 shadow-border transition-[background-color,box-shadow,color] duration-150 ease-out hover:text-foreground hover:shadow-border-hover disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-5",
className,
)}
{...props}
>
{children}
</motion.button>
<span
role="presentation"
aria-hidden
className="pointer-events-none absolute -top-10 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 text-xs font-medium whitespace-nowrap text-popover-foreground opacity-0 shadow-overlay transition-[opacity,translate] duration-150 ease-out group-has-[:focus-visible]:-translate-y-0.5 group-has-[:focus-visible]:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-hover:-translate-y-0.5 [@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100"
>
{label}
</span>
{active && (
<span
aria-hidden
className="absolute -bottom-1.5 left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground/60"
/>
)}
</div>
);
}
export function DockSeparator({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
role="separator"
aria-orientation="vertical"
className={cn("mx-0.5 h-8 w-px self-center bg-border", className)}
{...props}
/>
);
}