Developer launcher dock with smooth Gaussian proximity magnification, non-clipping headroom padding, tooltips, and Left/Right arrow keyboard navigation.
Preview
Customize
3 props1.42
115
42
Installation
Copies the component source and its CSS Module into your project.
npx motionfoundry add magnetic-dockWrites 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
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}