CoreDeveloper-Interface Animationsmotion/react

MagneticDock

Developer launcher dock with smooth Gaussian proximity magnification, non-clipping headroom padding, tooltips, and Left/Right arrow keyboard navigation.

Preview

Customize

3 props
1.42
115
42

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add magnetic-dock

Writes components/ui/magnetic-dock.tsx and components/styles/magnetic-dock.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { MagneticDock } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <MagneticDock />6  );7}

Props

PropTypeDefault
items

Dock launcher items.

MagneticDockItem[]DEFAULT_DOCK_ITEMS
maxScale

Peak magnification multiplier under the pointer.

number1.42
proximity

Gaussian influence radius in pixels.

number115
baseSize

Resting button dimension in pixels.

number42

Accessibility

Renders a semantic <nav> with Left/Right arrow key focus cycling, accessible aria-label on every dock button, and overflow-visible headroom so magnified items never clip.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/magnetic-dock.tsx
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/magnetic-dock.module.css";6 7export interface MagneticDockItem {8  id: string;9  label: string;10  glyph: string;11}12 13export interface MagneticDockProps {14  /** Dock launcher items */15  items?: MagneticDockItem[];16  /** Peak magnification multiplier */17  maxScale?: number;18  /** Proximity radius in pixels */19  proximity?: number;20  /** Base button size in pixels */21  baseSize?: number;22  /** Selection callback */23  onSelect?: (id: string) => void;24  /** Optional className */25  className?: string;26}27 28const DEFAULT_DOCK_ITEMS: MagneticDockItem[] = [29  { id: "terminal", label: "CLI Runner", glyph: "❯_" },30  { id: "registry", label: "Registry AST", glyph: "◈" },31  { id: "inspector", label: "Prop Inspector", glyph: "⌘" },32  { id: "profiler", label: "120Hz Profiler", glyph: "◎" },33  { id: "bundle", label: "ESM Bundle", glyph: "▤" },34];35 36export function MagneticDock({37  items = DEFAULT_DOCK_ITEMS,38  maxScale = 1.42,39  proximity = 115,40  baseSize = 42,41  onSelect,42  className = "",43}: MagneticDockProps) {44  const [pointerX, setPointerX] = React.useState<number | null>(null);45  const [activeId, setActiveId] = React.useState(items[0]?.id ?? "terminal");46  const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);47  const prefersReducedMotion = useReducedMotion();48 49  const computeScale = (idx: number) => {50    if (prefersReducedMotion || pointerX === null) return 1;51    const el = btnRefs.current[idx];52    if (!el) return 1;53    const rect = el.getBoundingClientRect();54    const center = rect.left + rect.width / 2;55    const dist = Math.abs(pointerX - center);56    if (dist >= proximity) return 1;57    const factor = Math.cos((dist / proximity) * (Math.PI / 2));58    return 1 + (maxScale - 1) * factor;59  };60 61  const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {62    if (e.key === "ArrowRight") {63      e.preventDefault();64      btnRefs.current[(idx + 1) % items.length]?.focus();65    } else if (e.key === "ArrowLeft") {66      e.preventDefault();67      btnRefs.current[(idx - 1 + items.length) % items.length]?.focus();68    }69  };70 71  return (72    <div className={styles.safeZone}>73      <nav74        aria-label="Developer magnetic dock"75        onPointerMove={(e) => setPointerX(e.clientX)}76        onPointerLeave={() => setPointerX(null)}77        className={`${styles.dock} ${className}`.trim()}78      >79        {items.map((item, idx) => {80          const scale = computeScale(idx);81          const isSelected = item.id === activeId;82          return (83            <motion.button84              key={item.id}85              ref={(el) => {86                btnRefs.current[idx] = el;87              }}88              type="button"89              aria-label={item.label}90              aria-current={isSelected ? "page" : undefined}91              onClick={() => {92                setActiveId(item.id);93                onSelect?.(item.id);94              }}95              onKeyDown={(e) => handleKeyDown(e, idx)}96              animate={{ scale, y: scale > 1.05 ? -(scale - 1) * 14 : 0 }}97              transition={{ type: "spring", stiffness: 320, damping: 22 }}98              style={{ width: `${baseSize}px`, height: `${baseSize}px` }}99              className={`${styles.item} ${isSelected ? styles.itemActive : ""}`.trim()}100            >101              <span className={styles.glyph}>{item.glyph}</span>102              <span className={styles.tooltip}>{item.label}</span>103              {isSelected && <span aria-hidden="true" className={styles.indicator} />}104            </motion.button>105          );106        })}107      </nav>108    </div>109  );110}