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.