A macOS-style dock whose icons grow with spring physics as the pointer approaches and push their neighbors apart. Labels, running dots, a launch bounce and arrow-key navigation included.
"use client"
import * as React from "react"
import {
Download,
Flower2,
Folder,
Mail,
MessageCircle,
Music2,
SquareTerminal,
StickyNote,
Trash2,
} from "lucide-react"
import { Dock, DockItem, DockSeparator } from "@/components/ballmac/dock"
const tile = "text-white [&_svg]:drop-shadow-[0_1px_1px_rgb(0_0_0/0.25)]"
export default function DockDemo() {
const [running, setRunning] = React.useState(["Files", "Messages", "Music", "Terminal"])
const launch = (name: string) => setRunning((r) => (r.includes(name) ? r : [...r, name]))
// Smaller screens get a gentler magnification so the dock stays inside its frame.
const [compact, setCompact] = React.useState(false)
React.useEffect(() => {
const query = window.matchMedia("(max-width: 639px)")
const update = () => setCompact(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
const item = (name: string) => ({ label: name, active: running.includes(name), onClick: () => launch(name) })
return (
<div className="relative isolate flex h-[320px] w-full max-w-[640px] items-end justify-center overflow-hidden rounded-2xl border border-foreground/10 px-3 pb-3">
{/* Wallpaper */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 dark:brightness-[0.6] dark:saturate-[1.2]">
<div className="absolute inset-0 bg-linear-to-br from-chart-1 via-chart-4 to-chart-5" />
<div className="absolute inset-0 bg-[radial-gradient(70%_60%_at_15%_100%,var(--chart-2),transparent_70%),radial-gradient(60%_55%_at_85%_0%,var(--chart-3),transparent_70%)] opacity-70" />
<div className="absolute -inset-x-1/4 top-[38%] h-[70%] rounded-[50%] bg-white/15 blur-2xl" />
<div className="absolute -inset-x-1/3 top-[58%] h-[80%] rounded-[50%] bg-white/10 blur-xl" />
</div>
<Dock size={42} magnification={compact ? 54 : 74} distance={compact ? 100 : 150} aria-label="Applications">
<DockItem {...item("Files")} icon={<Folder fill="currentColor" fillOpacity={0.25} />} className={`${tile} bg-linear-to-b from-chart-1/80 to-chart-1`} />
<DockItem {...item("Mail")} icon={<Mail />} containerClassName="max-[400px]:hidden" className={`${tile} bg-linear-to-b from-chart-1/70 to-chart-1`} />
<DockItem {...item("Messages")} icon={<MessageCircle fill="currentColor" />} className={`${tile} bg-linear-to-b from-chart-2/80 to-chart-2`} />
<DockItem {...item("Calendar")} className="@container bg-white text-black/85">
<span className="relative flex flex-col items-center leading-none">
<span className="text-[18cqw] font-semibold tracking-wide text-destructive uppercase">Sep</span>
<span className="-mt-[2cqw] text-[46cqw] font-light tracking-tight tabular-nums">29</span>
</span>
</DockItem>
<DockItem
{...item("Photos")}
icon={<Flower2 />}
containerClassName="max-sm:hidden"
className={tile}
style={{ background: "conic-gradient(from 20deg, var(--chart-3), var(--chart-5), var(--chart-4), var(--chart-1), var(--chart-2), var(--chart-3))" }}
/>
<DockItem {...item("Music")} icon={<Music2 />} containerClassName="max-sm:hidden" className={`${tile} bg-linear-to-b from-chart-5 to-chart-4`} />
<DockItem {...item("Notes")} icon={<StickyNote />} className={`${tile} bg-linear-to-b from-chart-3/80 to-chart-3`} />
<DockItem {...item("Terminal")} icon={<SquareTerminal />} containerClassName="max-sm:hidden" className={`${tile} bg-black/85`} />
<DockSeparator />
<DockItem label="Downloads" icon={<Download />} bounce={false} containerClassName="max-sm:hidden" className="bg-white/30 text-foreground/80 dark:bg-white/10" />
<DockItem label="Trash" icon={<Trash2 />} bounce={false} className="bg-white/30 text-foreground/80 dark:bg-white/10" />
</Dock>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/dockInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/directionCopy the source into your project.
components/ballmac/dock.tsx// Ballmac UI: Dock. https://ui.ballmac.com/components/dock "use client" import * as React from "react" import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue, } from "motion/react" import { cn } from "@/lib/utils" import { useDirection } from "@/lib/ballmac/direction" type DockOrientation = "horizontal" | "vertical" type DockContextValue = { pointer: MotionValue<number> size: number magnification: number distance: number orientation: DockOrientation reduceMotion: boolean } const DockContext = React.createContext<DockContextValue | null>(null) function useDock() { const context = React.useContext(DockContext) if (!context) throw new Error("DockItem and DockSeparator must be used inside <Dock>.") return context } const ITEM_SELECTOR = '[data-slot="dock-item"]:not([disabled])' function visibleItems(root: HTMLElement | null) { if (!root) return [] const shown = (el: Element | null) => !el || getComputedStyle(el).display !== "none" return Array.from(root.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter((el) => shown(el) && shown(el.parentElement)) } type DockProps = React.ComponentProps<"div"> & { /** Resting size of each item in px. */ size?: number /** Size in px of the item right under the pointer (or keyboard focus). */ magnification?: number /** How far in px from the pointer the magnification reaches. */ distance?: number /** Lay the dock out in a row (bottom of the screen) or a column (side of the screen). */ orientation?: DockOrientation } /** * A macOS-style dock. Items grow as the pointer approaches and push their neighbors apart. * It is a `toolbar`: Tab enters it once, arrow keys move between items, Home and End jump to the ends. */ function Dock({ size = 48, magnification = 76, distance = 150, orientation = "horizontal", className, style, children, onPointerMove, onPointerLeave, onKeyDown, onFocus, ...props }: DockProps) { const dir = useDirection() const ref = React.useRef<HTMLDivElement>(null) const pointer = useMotionValue(Number.POSITIVE_INFINITY) const reduceMotion = useReducedMotion() ?? false const vertical = orientation === "vertical" const current = React.useRef(0) // Roving tab stop: exactly one item is reachable with Tab. React.useEffect(() => { const items = visibleItems(ref.current) if (current.current >= items.length) current.current = 0 items.forEach((el, i) => (el.tabIndex = i === current.current ? 0 : -1)) }) const context = React.useMemo<DockContextValue>( () => ({ pointer, size, magnification: reduceMotion ? size : magnification, distance, orientation, reduceMotion }), [pointer, size, magnification, distance, orientation, reduceMotion] ) return ( <DockContext.Provider value={context}> <div ref={ref} role="toolbar" aria-orientation={orientation} data-slot="dock" data-orientation={orientation} className={cn( "relative isolate flex w-max max-w-full gap-1 rounded-[22px] p-1.5", vertical ? "flex-col items-center justify-center" : "items-end justify-center", // Vibrancy: translucent glass with a hairline edge and a lit top edge. "border border-white/40 bg-background/45 shadow-[inset_0_1px_0_0_rgb(255_255_255/0.45),0_0_0_0.5px_rgb(0_0_0/0.14),0_16px_40px_-12px_rgb(0_0_0/0.35)] backdrop-blur-2xl backdrop-saturate-150", "dark:border-white/12 dark:bg-background/40 dark:shadow-[inset_0_1px_0_0_rgb(255_255_255/0.1),0_0_0_0.5px_rgb(0_0_0/0.6),0_16px_40px_-12px_rgb(0_0_0/0.6)]", className )} style={{ [vertical ? "width" : "height"]: size + 14, ...style }} onPointerMove={(event) => { if (event.pointerType !== "touch") pointer.set(vertical ? event.clientY : event.clientX) onPointerMove?.(event) }} onPointerLeave={(event) => { pointer.set(Number.POSITIVE_INFINITY) onPointerLeave?.(event) }} onFocus={(event) => { const items = visibleItems(ref.current) const index = items.indexOf(event.target as HTMLElement) if (index !== -1) { current.current = index items.forEach((el, i) => (el.tabIndex = i === index ? 0 : -1)) } onFocus?.(event) }} onKeyDown={(event) => { onKeyDown?.(event) if (event.defaultPrevented) return const items = visibleItems(ref.current) const index = items.indexOf(document.activeElement as HTMLElement) if (index === -1) return const prev = vertical ? "ArrowUp" : dir === "rtl" ? "ArrowRight" : "ArrowLeft" const next = vertical ? "ArrowDown" : dir === "rtl" ? "ArrowLeft" : "ArrowRight" let target = -1 if (event.key === next) target = (index + 1) % items.length else if (event.key === prev) target = (index - 1 + items.length) % items.length else if (event.key === "Home") target = 0 else if (event.key === "End") target = items.length - 1 if (target === -1) return event.preventDefault() items[target].focus() }} {...props} > {children} </div> </DockContext.Provider> ) } type DockItemProps = Omit<React.ComponentProps<"button">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> & { /** Name shown in the label above the item; also the accessible name. */ label: string /** The icon, drawn centered on the tile at about half its size. */ icon?: React.ReactNode /** Custom tile content instead of `icon`. */ children?: React.ReactNode /** Shows the running indicator dot under the item. */ active?: boolean /** Text added to the accessible name when `active`, so the dot isn't the only signal. */ activeLabel?: string /** Bounce the item when it is clicked, like an app launching. */ bounce?: boolean /** Classes for the item's outer wrapper, e.g. `max-sm:hidden` to drop an item on small screens. `className` styles the tile. */ containerClassName?: string } function DockItem({ label, icon, children, active = false, activeLabel = "running", bounce = true, containerClassName, className, style, onClick, onPointerEnter, onPointerLeave, onFocus, onBlur, type = "button", ...props }: DockItemProps) { const { pointer, size, magnification, distance, orientation, reduceMotion } = useDock() const ref = React.useRef<HTMLButtonElement>(null) const vertical = orientation === "vertical" const [showLabel, setShowLabel] = React.useState(false) const focusedRef = React.useRef(false) // Distance from the pointer to this item's center, measured live so neighbors shift smoothly. const offset = useTransform(pointer, (value) => { const rect = ref.current?.getBoundingClientRect() if (!rect || !Number.isFinite(value)) return Number.POSITIVE_INFINITY return value - (vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2) }) const target = useTransform(offset, [-distance, 0, distance], [size, magnification, size], { clamp: true }) const itemSize = useSpring(target, { mass: 0.1, stiffness: 170, damping: 14 }) const lift = useMotionValue(0) const centerPointerOnSelf = () => { const rect = ref.current?.getBoundingClientRect() if (rect) pointer.set(vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2) } return ( <div data-slot="dock-item-wrapper" className={cn("relative flex shrink-0", vertical ? "flex-row items-center" : "flex-col items-center", containerClassName)} > <AnimatePresence> {showLabel ? ( <motion.span data-slot="dock-item-label" initial={{ opacity: 0, [vertical ? "x" : "y"]: 4 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }} className={cn( "pointer-events-none absolute z-10 whitespace-nowrap rounded-md border border-foreground/10 bg-background/80 px-2.5 py-1 text-xs font-medium text-foreground shadow-[0_4px_14px_-4px_rgb(0_0_0/0.3)] backdrop-blur-xl", vertical ? "start-full ms-3" : "bottom-full mb-2.5" )} aria-hidden="true" > {label} </motion.span> ) : null} </AnimatePresence> <motion.button ref={ref} type={type} data-slot="dock-item" data-active={active ? "" : undefined} aria-label={active ? `${label}, ${activeLabel}` : label} style={{ ...style, width: itemSize, height: itemSize, [vertical ? "x" : "y"]: lift }} className={cn( "group/dock-item relative flex shrink-0 items-center justify-center overflow-hidden rounded-[23%] bg-card text-foreground outline-none", "shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.22),0_1px_1px_rgb(0_0_0/0.12),0_4px_10px_-4px_rgb(0_0_0/0.35)]", "focus-visible:ring-[3px] focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50", "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[52%] [&_svg]:shrink-0", // Reduced motion: no magnification, a simple highlight instead (CSS, so server and client markup match). "motion-reduce:transition-[filter] motion-reduce:duration-150 motion-reduce:hover:brightness-110 motion-reduce:focus-visible:brightness-110", className )} onClick={(event) => { if (bounce && !reduceMotion) { const up = vertical ? 14 : -18 animate(lift, [0, up, 0, up * 0.4, 0], { duration: 0.7, times: [0, 0.3, 0.6, 0.8, 1], ease: "easeInOut" }) } onClick?.(event) }} onPointerEnter={(event) => { setShowLabel(true) onPointerEnter?.(event) }} onPointerLeave={(event) => { if (!focusedRef.current) setShowLabel(false) onPointerLeave?.(event) }} onFocus={(event) => { let visible = true try { visible = event.currentTarget.matches(":focus-visible") } catch {} if (visible) { focusedRef.current = true setShowLabel(true) centerPointerOnSelf() } onFocus?.(event) }} onBlur={(event) => { if (focusedRef.current) { focusedRef.current = false setShowLabel(false) pointer.set(Number.POSITIVE_INFINITY) } onBlur?.(event) }} {...props} > <span aria-hidden="true" data-slot="dock-item-sheen" className="pointer-events-none absolute inset-0 rounded-[inherit] bg-linear-to-b from-white/25 via-white/0 to-black/10" /> {icon ?? children} </motion.button> {active ? ( <span aria-hidden="true" data-slot="dock-item-indicator" className={cn( "pointer-events-none absolute size-1 rounded-full bg-foreground/70", vertical ? "-start-[4.5px] top-1/2 -mt-0.5" : "-bottom-[5px] left-1/2 -ms-0.5" )} /> ) : null} </div> ) } type DockSeparatorProps = React.ComponentProps<"div"> /** A hairline divider, like the one between apps and folders in the macOS Dock. */ function DockSeparator({ className, ...props }: DockSeparatorProps) { const { orientation } = useDock() const vertical = orientation === "vertical" return ( <div role="separator" aria-orientation={vertical ? "horizontal" : "vertical"} data-slot="dock-separator" className={cn( "shrink-0 self-stretch bg-foreground/20", vertical ? "mx-1.5 my-0.5 h-px" : "mx-0.5 my-1.5 w-px", className )} {...props} /> ) } export { Dock, DockItem, DockSeparator, type DockItemProps, type DockOrientation, type DockProps, type DockSeparatorProps }Update the import paths to match your project setup.
Usage
import { Dock, DockItem, DockSeparator } from "@/components/ballmac/dock"The full example is in the Code tab above.
Examples
Vertical
Tuesday, September 29
9:41
"use client"
import { Bell, Calendar, House, Inbox, Search, Settings, Star } from "lucide-react"
import { Dock, DockItem, DockSeparator } from "@/components/ballmac/dock"
const tile = "bg-background/70 text-foreground/80 dark:bg-white/10"
export default function DockVertical() {
return (
<div className="relative isolate flex h-[400px] w-full max-w-[560px] items-center overflow-hidden rounded-2xl border border-foreground/10 ps-3">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 dark:brightness-[0.55]">
<div className="absolute inset-0 bg-linear-to-bl from-chart-4 via-chart-1 to-chart-2" />
<div className="absolute inset-x-0 top-1/2 h-full rounded-[50%] bg-white/15 blur-2xl" />
</div>
<Dock orientation="vertical" size={38} magnification={58} distance={110} aria-label="Workspace">
<DockItem label="Home" icon={<House />} active className="bg-linear-to-b from-chart-1/80 to-chart-1 text-white" />
<DockItem label="Search" icon={<Search />} className={tile} />
<DockItem label="Inbox" icon={<Inbox />} active className={tile} />
<DockItem label="Calendar" icon={<Calendar />} className={tile} />
<DockItem label="Starred" icon={<Star />} className={tile} />
<DockSeparator />
<DockItem label="Notifications" icon={<Bell />} bounce={false} className={tile} />
<DockItem label="Settings" icon={<Settings />} bounce={false} className={tile} />
</Dock>
<div className="flex flex-1 flex-col items-center text-white [text-shadow:0_1px_12px_rgb(0_0_0/0.2)]">
<p className="text-sm font-medium opacity-90">Tuesday, September 29</p>
<p className="text-6xl font-semibold tracking-tight tabular-nums sm:text-7xl">9:41</p>
</div>
</div>
)
}API reference
<Dock>
| Prop | Type | Default |
|---|---|---|
sizeResting size of each item in px. | number | 48 |
magnificationSize in px of the item right under the pointer (or keyboard focus). | number | 76 |
distanceHow far in px from the pointer the magnification reaches. | number | 150 |
orientationLay the dock out in a row (bottom of the screen) or a column (side of the screen). | DockOrientation | "horizontal" |
<DockItem>
| Prop | Type | Default |
|---|---|---|
label*Name shown in the label above the item; also the accessible name. | string | — |
iconThe icon, drawn centered on the tile at about half its size. | React.ReactNode | — |
childrenCustom tile content instead of `icon`. | React.ReactNode | — |
activeShows the running indicator dot under the item. | boolean | false |
activeLabelText added to the accessible name when `active`, so the dot isn't the only signal. | string | "running" |
bounceBounce the item when it is clicked, like an app launching. | boolean | true |
containerClassNameClasses for the item's outer wrapper, e.g. `max-sm:hidden` to drop an item on small screens. `className` styles the tile. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves focus into the dock (one tab stop) and out again |
| ArrowLeft / ArrowRight (ArrowUp / ArrowDown when vertical) | Moves between items; the focused item magnifies and shows its label |
| Home / End | Jumps to the first or last item |
| Enter / Space | Activates the focused item |
Use with AI
<Dock> is a role=toolbar row (or column with orientation="vertical") of <DockItem label icon /> buttons with cursor-proximity magnification; <DockSeparator /> draws the divider. Style each tile through DockItem's className (gradients, colors); icons are sized to about half the tile. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Dock (@ballmac/dock) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An app-launcher or quick-links bar on a Mac-flavored landing page or product hero
- A floating navigation bar for a portfolio or dashboard with a handful of destinations
- Showing off the apps or integrations a product ships with
Not for
- Primary navigation with many text links (use a header or sidebar)
- More than about 12 items, or anywhere it would need to scroll horizontally
Registry JSON: https://ui.ballmac.com/r/dock.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/directionshadcn/utils
Pairs well with
Mac Window
macOS window chrome: traffic lights whose glyphs appear on hover, a centered title or unified toolbar, an optional translucent sidebar with vibrancy, and active and inactive appearances.
Menu Bar
A translucent macOS menu bar on Radix Menubar: a bold app menu, menus with key equivalents, submenus and checkmarks, a status area with icon buttons and a hydration-safe live clock.
Dynamic Island
A black pill or MacBook-style notch that morphs between live activities (timers, uploads, an AI thinking state, notifications) with a spring layout animation. Each view is announced politely.
Control Center
The macOS Control Center panel in frosted glass: toggle tiles with round badges, grouped Wi-Fi, Bluetooth and AirDrop rows, pill sliders with the icon in the track, and a Now Playing tile. Real toggle buttons and Radix sliders.