Components
Base Components
Dock
An app dock that can auto-hide and be positioned at any edge, with macOS-like zoom animation on hover.
Example
The dock shows a row (or column) of icons with tooltips. Hover an icon to see the macOS-like magnification. Use Position to choose the edge (top, bottom, left, right) and Auto-hide to switch between always visible and auto-hide (dock hides until you hover the edge).Position
Auto-hide off
Installation
Create a dock.tsx file and paste the following code into it.
"use client"import * as React from "react"import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"import { cn } from "@/lib/utils"const DOCK_POSITIONS = ["top", "bottom", "left", "right"] as constexport type DockPosition = (typeof DOCK_POSITIONS)[number]const DockContext = React.createContext<{ position: DockPosition hoveredIndex: number | null setHoveredIndex: (index: number | null) => void magnification: number itemCount: number} | null>(null)function useDock() { const ctx = React.useContext(DockContext) if (!ctx) throw new Error("DockItem must be used within Dock") return ctx}/** Base scale when no item is hovered; hovered item reaches magnification. */const MAGNIFICATION_DEFAULT = 1.4/** How much adjacent items scale (falloff). */const MAGNIFICATION_ADJACENT = 0.15/** Gap between the dock and the viewport edge (px). */const DOCK_EDGE_INSET = 24function getScale( index: number, hoveredIndex: number | null, magnification: number): number { if (hoveredIndex === null) return 1 const distance = Math.abs(index - hoveredIndex) if (distance === 0) return magnification const falloff = Math.max(0, magnification - 1 - distance * MAGNIFICATION_ADJACENT) return 1 + falloff}export interface DockProps extends React.ComponentProps<"div"> { /** Where the dock is anchored. */ position?: DockPosition /** When true, dock hides off-screen and shows on edge hover. */ autoHide?: boolean /** Scale of the hovered icon (macOS-like zoom). Default 1.4. */ magnification?: number /** Delay in ms before hiding when pointer leaves. Default 300. */ hideDelay?: number /** Thickness of the edge trigger zone in px for auto-hide. Default 24. */ triggerSize?: number}const Dock = React.forwardRef<HTMLDivElement, DockProps>( ( { position = "bottom", autoHide = false, magnification = MAGNIFICATION_DEFAULT, hideDelay = 300, triggerSize = 24, className, children, ...props }, ref ) => { const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null) const [visible, setVisible] = React.useState(!autoHide) const hideTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null) const items = React.Children.toArray(children).filter( (c): c is React.ReactElement => React.isValidElement(c) && (c.type as { displayName?: string })?.displayName === "DockItem" ) const itemCount = items.length // When autoHide is turned off, show dock; when turned on, hide it React.useEffect(() => { if (!autoHide) setVisible(true) else setVisible(false) }, [autoHide]) const showDock = React.useCallback(() => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current) hideTimeoutRef.current = null } setVisible(true) }, []) const scheduleHide = React.useCallback(() => { if (!autoHide) return hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelay) }, [autoHide, hideDelay]) const handleTriggerEnter = () => showDock() const handleTriggerLeave = () => scheduleHide() const handleDockEnter = () => showDock() const handleDockLeave = () => scheduleHide() React.useEffect(() => () => { hideTimeoutRef.current && clearTimeout(hideTimeoutRef.current) }, []) const isHorizontal = position === "top" || position === "bottom" // Translate the dock fully off-screen when hidden (include buffer so no sliver is visible) const hideOffsetPx = triggerSize + 4 const dockTranslate = (() => { if (!autoHide || visible) return "translate(0,0)" const offset = `calc(100% + ${hideOffsetPx}px)` const negOffset = `calc(-100% - ${hideOffsetPx}px)` switch (position) { case "top": return `translateY(${negOffset})` case "bottom": return `translateY(${offset})` case "left": return `translateX(${negOffset})` case "right": return `translateX(${offset})` } })() // When autoHide: container has zero size (doesn't block clicks). Trigger and dock use position:fixed so they're viewport-relative and work on all edges. const containerPositionClasses = { top: "inset-x-6 top-6", bottom: "inset-x-6 bottom-6", left: "inset-y-6 left-6", right: "inset-y-6 right-6", } const dockPositionClasses = { top: "flex-row justify-center pt-2", bottom: "flex-row justify-center pb-2", left: "flex-col items-center pl-2", right: "flex-col items-center pr-2", } const triggerPosition: React.CSSProperties = (() => { switch (position) { case "top": return { top: 0, left: 0, right: 0, height: triggerSize } case "bottom": return { bottom: 0, left: 0, right: 0, height: triggerSize } case "left": return { left: 0, top: 0, bottom: 0, width: triggerSize } case "right": return { right: 0, top: 0, bottom: 0, width: triggerSize } } })() const dockBarPosition: React.CSSProperties = (() => { const e = DOCK_EDGE_INSET // When auto-hide, the wrapper is 0×0 so the bar is `fixed` and needs viewport inset. // When always visible, the wrapper is already inset (`inset-x-6` etc.); bar is `absolute` at 0/0/0 inside it. if (autoHide) { switch (position) { case "top": return { top: e, left: e, right: e } case "bottom": return { bottom: e, left: e, right: e } case "left": return { left: e, top: e, bottom: e } case "right": return { right: e, top: e, bottom: e } } } switch (position) { case "top": return { top: 0, left: 0, right: 0 } case "bottom": return { bottom: 0, left: 0, right: 0 } case "left": return { left: 0, top: 0, bottom: 0 } case "right": return { right: 0, top: 0, bottom: 0 } } })() // Trigger: when autoHide use fixed so it's viewport-relative (works for top/left). Otherwise absolute inside container. const triggerStyle: React.CSSProperties = { position: autoHide ? "fixed" : "absolute", zIndex: 10, ...triggerPosition, } // Dock bar: when autoHide use fixed so viewport-relative. Hidden dock gets pointer-events-none. const dockBarStyle: React.CSSProperties = { position: autoHide ? "fixed" : "absolute", ...dockBarPosition, transform: dockTranslate, transition: "transform 0.2s ease-out", pointerEvents: autoHide && !visible ? ("none" as const) : ("auto" as const), zIndex: 5, } // When autoHide, omit padding on the edge side so the bar sits flush with the trigger (no gap). const dockBarClassName = cn( "absolute flex items-center gap-1 rounded-2xl border bg-background/80 px-4 py-1.5 shadow-lg backdrop-blur-md", isHorizontal ? "flex-row justify-center" : "flex-col items-center", position === "top" && (autoHide ? "pt-2" : "pt-4"), position === "bottom" && (autoHide ? "pb-0" : "pb-2"), position === "left" && (autoHide ? "pl-0" : "pl-2"), position === "right" && (autoHide ? "pr-2" : "pr-4") ) return ( <DockContext.Provider value={{ position, hoveredIndex, setHoveredIndex, magnification, itemCount, }} > <div ref={ref} className={cn( "fixed z-50", autoHide ? "left-0 top-0 h-0 w-0 overflow-visible" : containerPositionClasses[position], className )} data-slot="dock" data-position={position} {...props} > {autoHide && ( <div aria-hidden className="pointer-events-auto" style={triggerStyle} onMouseEnter={handleTriggerEnter} onMouseLeave={handleTriggerLeave} /> )} <div className={dockBarClassName} style={dockBarStyle} onMouseEnter={handleDockEnter} onMouseLeave={handleDockLeave} > {React.Children.map(children, (child, index) => { if (!React.isValidElement(child)) return child const item = child as React.ReactElement<{ index?: number }> if ((item.type as { displayName?: string })?.displayName !== "DockItem") { return child } return React.cloneElement(item, { index }) })} </div> </div> </DockContext.Provider> ) })Dock.displayName = "Dock"export interface DockItemProps extends Omit<React.ComponentProps<"button">, "children"> { /** Icon element (e.g. Lucide icon). */ icon: React.ReactNode /** Shown in tooltip on hover. */ label: React.ReactNode /** If set, renders as a link. */ href?: string /** Optional badge (e.g. notification dot). */ badge?: React.ReactNode /** Internal: set by Dock. */ index?: number children?: never}const DockItem = React.forwardRef<HTMLButtonElement, DockItemProps>( ( { icon, label, href, badge, index = 0, className, onMouseEnter, onMouseLeave, ...props }, ref ) => { const { position, hoveredIndex, setHoveredIndex, magnification, itemCount } = useDock() const scale = getScale(index, hoveredIndex, magnification) const isHorizontal = position === "top" || position === "bottom" const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => { setHoveredIndex(index ?? null) onMouseEnter?.(e as any) } const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => { setHoveredIndex(null) onMouseLeave?.(e as any) } const content = ( <span className="relative flex items-center justify-center transition-transform duration-150 ease-out" style={{ transform: `scale(${scale})` }} > <span className="flex size-10 items-center justify-center rounded-lg [&>svg]:size-6"> {icon} </span> {badge != null && ( <span className="absolute -right-0.5 -top-0.5 flex size-2.5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground"> {badge} </span> )} </span> ) const trigger = href ? ( <a href={href} className={cn( "flex cursor-pointer items-center justify-center rounded-lg text-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring", className )} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={ref as React.Ref<HTMLAnchorElement>} > {content} </a> ) : ( <button ref={ref} type="button" className={cn( "flex cursor-pointer items-center justify-center rounded-lg text-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring", className )} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props} > {content} </button> ) return ( <Tooltip> <TooltipTrigger asChild>{trigger}</TooltipTrigger> <TooltipContent side={ position === "top" ? "bottom" : position === "bottom" ? "top" : position === "left" ? "right" : "left" } sideOffset={8} > {label} </TooltipContent> </Tooltip> ) })DockItem.displayName = "DockItem"export { Dock, DockItem }Check the import paths to ensure they match your project setup.
Usage
UseDock as a fixed bar and DockItem for each icon. Set position to "top", "bottom", "left", or "right". Enable autoHide so the dock hides off-screen and appears when the pointer nears the edge.
import { Dock, DockItem } from "@/components/ui/dock";
import { HomeIcon, SettingsIcon } from "lucide-react";
<Dock position="bottom" autoHide>
<DockItem icon={<HomeIcon />} label="Home" />
<DockItem icon={<SettingsIcon />} label="Settings" />
</Dock>;Examples
The demo below is the same dock used in the component preview. One set of controls drives both position and auto-hide:- Position — Always visible: the dock stays on the chosen edge (top, bottom, left, right).
- Auto-hide off (default) — Same as above; the dock is always visible.
- Auto-hide on — The dock hides off-screen; hover the edge to show it, move away to hide it.
API Reference
Dock
position"left" | "right" | "top" | "bottom"
Default: bottom
Where the dock is anchored.
autoHideboolean
Default: false
When true, dock hides off-screen and shows on edge hover.
magnificationnumber
Default: 1.4
Scale of the hovered icon (macOS-like zoom). Default 1.4.
hideDelaynumber
Default: 300
Delay in ms before hiding when pointer leaves. Default 300.
triggerSizenumber
Default: 24
Thickness of the edge trigger zone in px for auto-hide. Default 24.
| Prop | Type | Default | Description |
|---|---|---|---|
| position | "left" | "right" | "top" | "bottom" | bottom | Where the dock is anchored. |
| autoHide | boolean | false | When true, dock hides off-screen and shows on edge hover. |
| magnification | number | 1.4 | Scale of the hovered icon (macOS-like zoom). Default 1.4. |
| hideDelay | number | 300 | Delay in ms before hiding when pointer leaves. Default 300. |
| triggerSize | number | 24 | Thickness of the edge trigger zone in px for auto-hide. Default 24. |
| Prop | Type | Default | Description |
|---|---|---|---|
position | "top" | "bottom" | "left" | "right" | "bottom" | Where the dock is anchored. |
autoHide | boolean | false | When true, dock hides off-screen and shows on edge hover. |
magnification | number | 1.4 | Scale of the hovered icon (macOS-like zoom). |
hideDelay | number | 300 | Delay in ms before hiding when pointer leaves. |
triggerSize | number | 24 | Thickness in px of the edge trigger zone for auto-hide. |
DockItem
| Prop | Type | Description |
|---|---|---|
icon | React.ReactNode | Icon element (e.g. Lucide icon). |
label | React.ReactNode | Shown in tooltip on hover. |
href | string | If set, the item renders as a link. |
badge | React.ReactNode | Optional badge (e.g. notification count). |