Dock Cursor Magnify

Navigation

Cursor-proximity magnification row with Gaussian falloff — macOS-dock physics for any children.

Install
npx shadcn add https://design.helpmarq.com/r/dock-cursor-magnify.json
Source
registry/core/dock-cursor-magnify/component.tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";

// Gaussian-falloff magnification on a direct-DOM rAF loop — no React state
// on the hot path, interruptible easing on enter/leave.
export function MagneticDock({
  children,
  gain = 0.55,
  sigma = 80,
  lift = 16,
  className = "",
}: {
  children: ReactNode;
  /** max extra scale at zero distance (0.55 → 1.55x) */
  gain?: number;
  /** gaussian falloff radius in px */
  sigma?: number;
  /** upward shift in px at full magnification */
  lift?: number;
  className?: string;
}) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    let items: HTMLElement[] = [];
    let scales: number[] = [];
    let cursorX: number | null = null;
    let raf = 0;

    // re-sync from the live DOM whenever children are added/removed/reordered
    // — keeps the loop from animating a stale snapshot (and stale detached nodes).
    const sync = () => {
      const next = Array.from(container.children) as HTMLElement[];
      scales = next.map((el) => {
        const i = items.indexOf(el);
        return i === -1 ? 1 : scales[i];
      });
      items = next;
    };
    sync();

    const loop = () => {
      let settled = true;
      // untransformed centers (offsetLeft ignores transforms — no feedback wobble)
      const left = container.getBoundingClientRect().left;
      const centers = items.map((el) => left + el.offsetLeft + el.offsetWidth / 2);
      items.forEach((el, i) => {
        let target = 1;
        if (cursorX !== null) {
          const d = cursorX - centers[i];
          target = 1 + gain * Math.exp(-(d * d) / (2 * sigma * sigma));
        }
        scales[i] += (target - scales[i]) * 0.18;
        if (Math.abs(target - scales[i]) > 0.002) settled = false;
      });
      items.forEach((el, i) => {
        // neighbors shove outward so grown items never overlap
        let shift = 0;
        for (let j = 0; j < items.length; j++) {
          if (j === i) continue;
          const push = (scales[j] - 1) * items[j].offsetWidth * 0.55;
          shift += j < i ? push : -push;
        }
        const s = scales[i];
        el.style.transform = `translate(${shift}px, ${-(s - 1) * lift}px) scale(${s})`;
        // magnification amount 0..1 — sells depth beyond scale/lift alone,
        // color-mix(currentColor) so the glow adapts to each theme automatically.
        const t = Math.max(0, (s - 1) / gain);
        if (t > 0.002) {
          el.style.filter = `brightness(${1 + t * 0.12})`;
          el.style.boxShadow = `0 ${4 + t * 10}px ${10 + t * 16}px -4px color-mix(in srgb, currentColor ${Math.round(15 + t * 25)}%, transparent)`;
        } else {
          el.style.filter = "";
          el.style.boxShadow = "";
        }
      });
      raf = settled && cursorX === null ? 0 : requestAnimationFrame(loop);
    };
    const wake = () => {
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const onMove = (e: PointerEvent) => {
      cursorX = e.clientX;
      wake();
    };
    const onLeave = () => {
      cursorX = null;
      wake();
    };

    const observer = new MutationObserver(() => {
      sync();
      wake();
    });
    observer.observe(container, { childList: true });

    container.addEventListener("pointermove", onMove);
    container.addEventListener("pointerleave", onLeave);
    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
      container.removeEventListener("pointermove", onMove);
      container.removeEventListener("pointerleave", onLeave);
    };
  }, [gain, sigma, lift]);

  return (
    <div
      ref={containerRef}
      className={`flex items-end gap-2 [&>*]:origin-bottom [&>*]:will-change-transform ${className}`}
    >
      {children}
    </div>
  );
}
Use when

a macOS-dock-style row that magnifies items under cursor proximity with Gaussian falloff; use for an icon/action dock or nav row, not for showing scroll/read position through a document.

Build spec

A horizontal dock that magnifies its children based on cursor proximity with a Gaussian distance falloff: items scale up and lift as the pointer nears, neighbors swell progressively less, everything settles back with interruptible easing on leave. Direct-DOM rAF loop with no React state on the hot path, transform-origin bottom, static under prefers-reduced-motion.

Tags
navcursordockmicro-interaction