UI Library
Dock rail
A vertical icon rail that magnifies like the macOS Dock: the icon under the pointer grows to 1.5x, its neighbours grow less, and the rail pushes apart to make room instead of overlapping. Each icon carries a tooltip and an optional status dot. Motion is dropped under prefers-reduced-motion and on touch.
Navigationdockrailsidebarmagnificationtooltip
Preview
apps/site/blocks/components/DockRail.tsx
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FileText, GraduationCap, MapPin, Route, Truck, Users, type LucideIcon } from 'lucide-react';
export interface DockRailItem {
key: string;
/** Shown in the tooltip, and the button's accessible name. */
label: string;
icon: LucideIcon;
/** Green dot — the section is complete. */
complete?: boolean;
/** Red dot — something in the section needs attention. Wins over `complete`. */
hasError?: boolean;
}
// ---------------------------------------------------------------------------
// Magnification
// ---------------------------------------------------------------------------
/** How much the icon directly under the pointer grows: 1.5x at the centre. */
const MAX_GROWTH = 0.5;
/** How far the pointer's influence reaches, in px — about two and a half icons. */
const INFLUENCE_PX = 104;
/**
* Scale for an icon `distance` px from the pointer. Smoothstep rather than a
* straight ramp: a linear falloff leaves a visible crease where growth starts,
* which reads as the rail snapping rather than swelling.
*/
export function dockScale(distance: number): number {
const t = Math.max(0, Math.min(1, 1 - Math.abs(distance) / INFLUENCE_PX));
return 1 + MAX_GROWTH * t * t * (3 - 2 * t);
}
type Box = { x: number; y: number; w: number; h: number };
function useDockMagnification(count: number) {
const railRef = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const [boxes, setBoxes] = useState<Box[]>([]);
const [pointer, setPointer] = useState<{ x: number; y: number } | null>(null);
// On unless the viewer asked for less motion — a missing matchMedia should
// not quietly switch the feature off.
const [magnifies, setMagnifies] = useState(true);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const apply = () => setMagnifies(!query.matches);
apply();
query.addEventListener?.('change', apply);
return () => query.removeEventListener?.('change', apply);
}, []);
// Centres are measured, never assumed from a pitch constant, and measured at
// rest — an item mid-transform would report its magnified size.
const measure = useCallback(() => {
const rail = railRef.current;
if (!rail) return;
const railBox = rail.getBoundingClientRect();
const next = itemRefs.current.slice(0, count).map((el) => {
if (!el) return { x: 0, y: 0, w: 0, h: 0 };
const box = el.getBoundingClientRect();
return {
x: box.left - railBox.left + box.width / 2,
y: box.top - railBox.top + box.height / 2,
w: box.width,
h: box.height,
};
});
// Unchanged → keep the old array, or the ResizeObserver loops for ever.
setBoxes((prev) =>
prev.length === next.length &&
prev.every((b, i) => b.x === next[i].x && b.y === next[i].y && b.w === next[i].w && b.h === next[i].h)
? prev
: next,
);
}, [count]);
// Measured when the rail element arrives, then on every resize — a rail is
// often mounted before it is visible (inside a closed popover), and a
// mount-time measurement would find nothing.
const attachRail = useCallback(
(el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
railRef.current = el;
if (!el) return;
measure();
if (typeof ResizeObserver === 'undefined') return;
observerRef.current = new ResizeObserver(() => measure());
observerRef.current.observe(el);
},
[measure],
);
useEffect(() => () => observerRef.current?.disconnect(), []);
/**
* Scaling alone is not the Dock: a 40px icon at 1.5x needs 60px in a 44px
* slot, so on a packed rail the growth is spent overlapping neighbours. The
* icons have to push apart — re-lay the rail at the scaled sizes keeping the
* original gaps, then slide the result so the point under the cursor stays
* under the cursor. Each icon's shift is a translate, so nothing reflows.
*/
const layout = useMemo(() => {
const still = boxes.map(() => ({ scale: 1, offset: 0 }));
if (!magnifies || !pointer || boxes.length < 2) return still;
// Falloff is 2D, so a pointer drifting off the side eases the whole rail out.
const scales = boxes.map((b) => dockScale(Math.hypot(pointer.x - b.x, pointer.y - b.y)));
const sizes = boxes.map((b, i) => b.h * scales[i]);
const grown: number[] = [boxes[0].y];
for (let i = 1; i < boxes.length; i++) {
const gap = boxes[i].y - boxes[i - 1].y - (boxes[i - 1].h + boxes[i].h) / 2;
grown[i] = grown[i - 1] + sizes[i - 1] / 2 + gap + sizes[i] / 2;
}
// Anchor on the cursor, or the rail grows off one end and the icon being
// aimed at slides out from under the pointer.
const p = pointer.y;
const last = boxes.length - 1;
let anchor: number;
if (p <= boxes[0].y) anchor = grown[0] + (p - boxes[0].y);
else if (p >= boxes[last].y) anchor = grown[last] + (p - boxes[last].y);
else {
const i = boxes.findIndex((_, k) => k < last && p >= boxes[k].y && p <= boxes[k + 1].y);
const span = boxes[i + 1].y - boxes[i].y;
const t = span === 0 ? 0 : (p - boxes[i].y) / span;
anchor = grown[i] + t * (grown[i + 1] - grown[i]);
}
const shift = p - anchor;
return boxes.map((b, i) => ({ scale: scales[i], offset: grown[i] + shift - b.y }));
}, [boxes, pointer, magnifies]);
const railProps = {
ref: attachRail,
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
// A touch "hover" is a tap that already landed; growing the icon under a
// finger only hides it.
if (!magnifies || e.pointerType === 'touch') return;
if (boxes.length !== count) measure();
const railBox = e.currentTarget.getBoundingClientRect();
setPointer({ x: e.clientX - railBox.left, y: e.clientY - railBox.top });
},
onPointerLeave: () => setPointer(null),
};
const itemProps = (index: number) => {
const { scale, offset } = layout[index] || { scale: 1, offset: 0 };
const moved = Math.abs(offset) > 0.5;
return {
ref: (el: HTMLElement | null) => {
itemRefs.current[index] = el;
},
style: {
// Translate before scale, so the shift itself is not magnified.
transform:
scale === 1 && !moved
? undefined
: `${moved ? `translateY(${offset.toFixed(2)}px) ` : ''}scale(${scale.toFixed(3)})`,
// The biggest icon paints over its neighbours.
zIndex: scale > 1.01 ? Math.round(scale * 10) : undefined,
},
};
};
/**
* Where an icon's right edge and vertical centre sit right now, magnification
* included. Icons scale from their left edge (origin-left), so only the right
* edge moves sideways.
*/
const anchorOf = (index: number) => {
const box = boxes[index];
if (!box) return null;
const { scale, offset } = layout[index] || { scale: 1, offset: 0 };
return { right: box.x - box.w / 2 + box.w * scale, y: box.y + offset };
};
return { railProps, itemProps, anchorOf };
}
// ---------------------------------------------------------------------------
// The rail
// ---------------------------------------------------------------------------
const DEFAULT_ITEMS: DockRailItem[] = [
{ key: 'vehicle', label: 'Vehicle', icon: Truck, complete: true },
{ key: 'route', label: 'Route', icon: Route, complete: true },
{ key: 'stops', label: 'Stops', icon: MapPin, hasError: true },
{ key: 'training', label: 'Training', icon: GraduationCap },
{ key: 'attendees', label: 'Attendees', icon: Users, complete: true },
{ key: 'documents', label: 'Documents', icon: FileText },
];
export function DockRail({
items = DEFAULT_ITEMS,
activeKey,
onSelect,
label = 'Sections',
}: {
items?: DockRailItem[];
/** Controlled selection. Omit it and the rail tracks the last click itself. */
activeKey?: string;
onSelect?: (key: string) => void;
/** Names the rail for assistive tech. */
label?: string;
}) {
const dock = useDockMagnification(items.length);
const [ownActive, setOwnActive] = useState(items[0]?.key);
const active = activeKey ?? ownActive;
// One tooltip for the whole rail, positioned from the live layout — a
// tooltip inside the button would be magnified along with the icon.
const [hovered, setHovered] = useState<number | null>(null);
const tip = hovered === null ? null : dock.anchorOf(hovered);
return (
<div
{...dock.railProps}
role="group"
aria-label={label}
className="relative flex flex-col items-center gap-1 border-r border-border py-6 pr-3"
>
{items.map((item, index) => {
const Icon = item.icon;
const isActive = item.key === active;
return (
<button
key={item.key}
{...dock.itemProps(index)}
type="button"
aria-label={item.label}
aria-current={isActive ? 'true' : undefined}
onClick={() => {
setOwnActive(item.key);
onSelect?.(item.key);
}}
onPointerEnter={() => setHovered(index)}
onPointerLeave={() => setHovered((h) => (h === index ? null : h))}
onFocus={() => setHovered(index)}
onBlur={() => setHovered((h) => (h === index ? null : h))}
// origin-left: grow away from the rule the rail sits on, never
// leaning out over the page edge.
className={
isActive
? 'relative flex h-10 w-10 shrink-0 origin-left items-center justify-center rounded-lg border border-primary/30 bg-primary/10 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40'
: 'relative flex h-10 w-10 shrink-0 origin-left items-center justify-center rounded-lg border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40'
}
>
<Icon size={18} />
{item.hasError ? (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-rose-500" aria-hidden />
) : item.complete ? (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden />
) : null}
</button>
);
})}
{tip && hovered !== null && (
<div
role="tooltip"
className="pointer-events-none absolute z-50 -translate-y-1/2 whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md"
style={{ left: tip.right + 8, top: tip.y }}
>
{items[hovered].label}
</div>
)}
</div>
);
}
Dependencies
lucide-react— npm package
Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.