Floating Dock
A little lift for every destination. Icons grow and make room as your pointer passes.
'use client'
import { useEffect, useRef } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import './floating-dock.css'
export interface FloatingDockItem {
id: string
label: string
href: string
icon: ReactNode
}
export interface FloatingDockProps {
items: FloatingDockItem[]
activeId?: string
className?: string
label?: string
onNavigate?: (id: string, event: MouseEvent<HTMLAnchorElement>) => void
}
/** A magnifying navigation dock. Icons are decorative; every link keeps its own label. */
export function FloatingDock({
items,
activeId,
className = '',
label = 'Primary navigation',
onNavigate,
}: FloatingDockProps) {
const trackRef = useRef<HTMLDivElement>(null)
const linksRef = useRef(new Map<string, HTMLAnchorElement>())
const frameRef = useRef(0)
const pointerRef = useRef<number | null>(null)
function reset() {
cancelAnimationFrame(frameRef.current)
frameRef.current = 0
pointerRef.current = null
linksRef.current.forEach((link) => {
link.style.removeProperty('--ac-dock-proximity')
link.style.removeProperty('--ac-dock-shift')
})
}
function magnify(clientX: number) {
if (
!window.matchMedia('(hover: hover) and (pointer: fine)').matches ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) return
pointerRef.current = clientX
if (frameRef.current) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
const track = trackRef.current
const pointer = pointerRef.current
if (!track || pointer === null) return
const left = track.getBoundingClientRect().left
linksRef.current.forEach((link) => {
// Measure the fixed hit target, not the transformed artwork inside it.
const distance = left + link.offsetLeft + link.offsetWidth / 2 - pointer
const proximity = (Math.cos(Math.min(Math.abs(distance) / 120, 1) * Math.PI) + 1) / 2
link.style.setProperty('--ac-dock-proximity', proximity.toFixed(3))
link.style.setProperty('--ac-dock-shift', `${(Math.sign(distance) * proximity * 10).toFixed(2)}px`)
})
})
}
useEffect(() => {
const track = trackRef.current
if (!track) return
const observer = new ResizeObserver(reset)
observer.observe(track)
const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
const pointer = window.matchMedia('(hover: hover) and (pointer: fine)')
motion.addEventListener('change', reset)
pointer.addEventListener('change', reset)
return () => {
observer.disconnect()
motion.removeEventListener('change', reset)
pointer.removeEventListener('change', reset)
cancelAnimationFrame(frameRef.current)
}
}, [])
function moveFocus(event: KeyboardEvent<HTMLAnchorElement>, index: number) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || items.length < 2) return
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'
let next = index
if (event.key === 'ArrowRight') next += rtl ? -1 : 1
else if (event.key === 'ArrowLeft') next += rtl ? 1 : -1
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = items.length - 1
else return
event.preventDefault()
const link = linksRef.current.get(items[(next + items.length) % items.length].id)
link?.focus({ preventScroll: true })
link?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
return (
<nav
aria-label={label}
className={`ac-dock ${className}`.trim()}
onPointerMove={(event) => {
if (event.pointerType === 'mouse') magnify(event.clientX)
}}
onPointerLeave={reset}
onPointerCancel={reset}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) reset()
}}
>
<div className="ac-dock-viewport" onScroll={reset}>
<div className="ac-dock-track" ref={trackRef}>
{items.map((item, index) => (
<a
key={item.id}
ref={(node) => {
if (node) linksRef.current.set(item.id, node)
else linksRef.current.delete(item.id)
}}
className="ac-dock-link"
href={item.href}
aria-label={item.label}
aria-current={item.id === activeId ? 'page' : undefined}
onFocus={(event) => {
const bounds = event.currentTarget.getBoundingClientRect()
magnify(bounds.left + bounds.width / 2)
}}
onKeyDown={(event) => moveFocus(event, index)}
onClick={(event) => onNavigate?.(item.id, event)}
>
<span className="ac-dock-artwork" aria-hidden="true">{item.icon}</span>
<span className="ac-dock-label" aria-hidden="true">{item.label}</span>
<span className="ac-dock-dot" aria-hidden="true" />
</a>
))}
</div>
</div>
</nav>
)
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/floating-dock.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { FloatingDock } from '@/components/anticraft/FloatingDock';
export function Example() {
return (
<FloatingDock
activeId="work"
label="Studio navigation"
items={[
{ id: 'work', label: 'Work', href: '/#work', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="4" y="4" width="16" height="16" rx="3" /><path d="M4 10h16M10 10v10" /></svg> },
{ id: 'about', label: 'About', href: '/#about', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="12" cy="8" r="3" /><path d="M5 21v-2a7 7 0 0 1 14 0v2" /></svg> },
{ id: 'contact', label: 'Contact', href: '/#contact', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 6 9 7 9-7" /></svg> },
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | FloatingDockItem[] | Required | Destinations with a unique id, visible label, and real href. Replace the example URLs with destinations in your project. |
activeId | string | undefined | The controlled id of the current destination. Only that link receives aria-current="page". Update this from your route or selected section. |
label | string | 'Primary navigation' | The accessible navigation label. Disclosure components also use it for their visible trigger. |
onNavigate | (id: string, event: MouseEvent<HTMLAnchorElement>) => void | undefined | Runs when a destination is activated. Anchors navigate normally; call event.preventDefault() only when handling navigation yourself, such as a local preview or router adapter. |
className | string | '' | Additional classes for sizing and CSS custom properties. Each component imports its matching stylesheet; keep both files together. |
items[].icon | ReactNode | Required | Decorative artwork for each destination. Links receive their accessible name from label; do not put interactive elements inside icons. |
Motion & accessibility
Fine mouse movement magnifies nearby artwork while the fixed link targets remain easy to hit. Hover or keyboard focus reveals a label; the current destination has a small marker. Tab reaches every link, and Left/Right, Home, and End provide optional shortcuts. Long docks scroll horizontally. Coarse pointers and reduced motion keep artwork still. Customize the surface, icons, label, and focus colors with --ac-dock-* properties.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.