Dock

macOS-style dock whose icons magnify near the pointer.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/dock.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import * as React from "react"; import { cn } from "../../lib/utils"; /** Props for {@link Dock}. */ export type DockProps = React.ComponentPropsWithoutRef<"div">; /** Props for {@link DockIcon}. */ export type DockIconProps = React.ComponentPropsWithoutRef<"div">; const DockPointerContext = React.createContext<null | number>(null); function assignRef( ref: React.Ref<HTMLDivElement> | undefined, node: HTMLDivElement | null, ): void { if (typeof ref === "function") { ref(node); return; } if (ref) { ref.current = node; } } function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = React.useState(false); React.useEffect(() => { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { return; } const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const onChange = (): void => { setReduced(query.matches); }; onChange(); query.addEventListener("change", onChange); return () => { query.removeEventListener("change", onChange); }; }, []); return reduced; } function magnify(distance: number): number { const range = 100; const clamped = Math.min(Math.abs(distance), range); return 1 + 0.5 * (1 - clamped / range); } /** * macOS-style dock that magnifies its {@link DockIcon} children near the pointer. * * @example * ```tsx * <Dock> * <DockIcon>A</DockIcon> * <DockIcon>B</DockIcon> * </Dock> * ``` */ export const Dock = ({ children, className, ref, ...props }: DockProps & { ref?: React.Ref<HTMLDivElement> }) => { const [pointerX, setPointerX] = React.useState<null | number>(null); return ( <DockPointerContext.Provider value={pointerX}> <div className={cn( "flex items-end gap-2 rounded-2xl border bg-card/60 p-2 backdrop-blur", className, )} onPointerLeave={() => { setPointerX(null); }} onPointerMove={(event) => { setPointerX(event.clientX); }} ref={ref} {...props} > {children} </div> </DockPointerContext.Provider> ); }; Dock.displayName = "Dock"; function useDockScale( reference: React.RefObject<HTMLDivElement | null>, pointerX: null | number, reduced: boolean, ): number { if (reduced || pointerX === null || reference.current === null) { return 1; } const bounds = reference.current.getBoundingClientRect(); const center = bounds.left + bounds.width / 2; return magnify(pointerX - center); } /** * Single dock entry that scales up as the pointer moves toward its center. * * Respects `prefers-reduced-motion`: the icon stays at rest size. * * @example * ```tsx * <DockIcon>Home</DockIcon> * ``` */ export const DockIcon = ({ children, className, ref, style, ...props }: DockIconProps & { ref?: React.Ref<HTMLDivElement> }) => { const pointerX = React.use(DockPointerContext); const reduced = usePrefersReducedMotion(); const reference = React.useRef<HTMLDivElement>(null); const scale = useDockScale(reference, pointerX, reduced); const setReferences = React.useCallback( (node: HTMLDivElement | null): void => { reference.current = node; assignRef(ref, node); }, [ref], ); return ( <div className={cn( "flex aspect-square w-12 items-center justify-center rounded-xl bg-accent text-accent-foreground transition-transform", className, )} ref={setReferences} style={{ transform: `scale(${scale})`, ...style }} {...props} > {children} </div> ); }; DockIcon.displayName = "DockIcon";

Dependencies

  • @vllnt/ui@^0.2.1