dock
A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call dock() with no arguments for a working demo, or edit the code below live.
Implementation notes
Continuous per-frame magnification driven by live cursor X via rAF-throttled pointermove, smoothstep falloff from icon distance/proximityMultiplier, imperative DOM transform writes (not Domphy reactivity, per the continuous-effect guidance), tooltip integration, separators, anchor-based tooltip placement/transform-origin, disableMagnification toggle, per-icon href/onClick. Gap: no literal spring-physics integrator (mass/stiffness/damping) — approximated with a bouncy CSS cubic-bezier transition on transform, which gives the same qualitative overshoot-then-settle feel driven by continuously recomputed targets, but isn't a real physics simulation.
Status: ported · Reference: Magic UI original
// magicui "Dock" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// macOS-style row of circular icon buttons inside a floating translucent
// pill that magnify smoothly as the cursor approaches — closest icon grows
// largest, neighbors grow progressively less by distance, and everything
// relaxes back to rest size when the cursor leaves.
//
// True mass/stiffness/damping spring physics aren't implemented (Domphy has
// no bundled spring integrator); instead each icon's `transform` is driven
// directly (imperative DOM writes, not Domphy reactivity — this is a
// continuous, high-frequency effect, matching the "canvas loop / marquee"
// guidance for such effects) from live cursor position, rAF-throttled, and
// eased through a bouncy CSS `cubic-bezier` transition so the icon overshoots
// slightly before settling — a visual approximation of a damped spring, not
// a literal one.
import type { DomphyElement, ElementNode, Listener } from "@domphy/core";
import { tooltip } from "@domphy/ui";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
export type DockIconName =
| "home"
| "search"
| "chat"
| "gallery"
| "settings"
| "globe"
| "mail";
export interface DockItem {
icon: DockIconName;
label: string;
href?: string;
onClick?: (event: MouseEvent) => void;
}
export type DockEntry = DockItem | { separator: true };
export type DockAnchor = "top" | "middle" | "bottom";
export interface DockProps {
/** Icon buttons (and optional `{ separator: true }` group dividers). Defaults to a 7-icon demo dock. */
items?: DockEntry[];
/** Icon diameter, in `themeSpacing` units. Defaults to 10 (~40px at the base font size). */
iconSizeUnits?: number;
/** Max scale multiplier reached at closest cursor proximity. Defaults to 1.5. */
magnification?: number;
/** Proximity falloff width, as a multiple of the icon's own rendered size. Defaults to 3.5 (~140px at 40px icons). */
proximityMultiplier?: number;
/** Which edge the dock is anchored against — flips tooltip placement and each icon's grow-from origin. Defaults to "bottom". */
anchor?: DockAnchor;
/** Disables the magnification effect entirely, falling back to static icons. Defaults to false. */
disableMagnification?: boolean;
}
const DEFAULT_ITEMS: DockEntry[] = [
{ icon: "home", label: "Home", href: "#" },
{ icon: "search", label: "Search", href: "#" },
{ icon: "chat", label: "Messages", href: "#" },
{ icon: "gallery", label: "Gallery", href: "#" },
{ icon: "settings", label: "Settings", href: "#" },
{ separator: true },
{ icon: "globe", label: "Website", href: "#" },
{ icon: "mail", label: "Mail", href: "#" },
];
// ---------------------------------------------------------------------------
// Hand-authored generic line icons (24x24, stroke=currentColor) — simple
// geometric silhouettes, not sourced from or tracing any icon library or
// platform's trademarked logo.
// ---------------------------------------------------------------------------
const ICON_SHAPES: Record<DockIconName, DomphyElement[]> = {
home: [
{ polyline: null, points: "4,12 12,5 20,12" },
{ rect: null, x: "6", y: "12", width: "12", height: "8" },
],
search: [
{ circle: null, cx: "10", cy: "10", r: "6" },
{ line: null, x1: "15", y1: "15", x2: "20", y2: "20" },
],
chat: [
{
path: null,
d: "M4 5h16a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z",
},
],
gallery: [
{ rect: null, x: "3", y: "4", width: "18", height: "14", rx: "2" },
{ circle: null, cx: "8", cy: "10", r: "1.5" },
{ polyline: null, points: "3,17 9,12 14,16 21,10" },
],
settings: [
{ circle: null, cx: "12", cy: "12", r: "3" },
{ circle: null, cx: "12", cy: "12", r: "8" },
],
globe: [
{ circle: null, cx: "12", cy: "12", r: "9" },
{ line: null, x1: "3", y1: "12", x2: "21", y2: "12" },
{ path: null, d: "M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" },
],
mail: [
{ rect: null, x: "3", y: "5", width: "18", height: "14", rx: "2" },
{ polyline: null, points: "3,7 12,13 21,7" },
],
};
function dockGlyph(name: DockIconName): DomphyElement<"svg"> {
return {
svg: ICON_SHAPES[name],
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "1.75",
strokeLinecap: "round",
strokeLinejoin: "round",
role: "img",
ariaHidden: "true",
style: { width: "55%", height: "55%" },
} as DomphyElement<"svg">;
}
/** Hairline vertical divider between logical icon groups. */
function dockSeparator(index: number): DomphyElement<"div"> {
// `_doctorDisable` is a doctor-only annotation not present in core's strict
// `PartialElement` type — build through an untyped literal, then assert, so
// the excess-property check doesn't fire (mirrors the shadcn sidebar
// family's `verticalDivider()`). Decorative separator with no text of its
// own, drawn as a border (not a backgroundColor fill).
const element = {
div: null,
ariaHidden: "true",
_key: `separator-${index}`,
_doctorDisable: "missing-color",
style: {
alignSelf: "stretch",
borderInlineStart: (listener: Listener) => `1px solid ${themeColor(listener, "shift-4")}`,
},
};
return element as DomphyElement<"div">;
}
interface DockIconRef {
element: HTMLElement;
}
function dockIconButton(
item: DockItem,
index: number,
iconSizeUnits: number,
anchor: DockAnchor,
iconRefs: DockIconRef[],
): DomphyElement<"a"> {
const tooltipPlacement = anchor === "top" ? "bottom" : "top";
const transformOrigin =
anchor === "top" ? "center top" : anchor === "bottom" ? "center bottom" : "center center";
const anchorElement: DomphyElement<"a"> = {
a: [dockGlyph(item.icon)],
href: item.href ?? "#",
ariaLabel: item.label,
_key: `icon-${index}`,
dataTone: "shift-16",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: "0",
width: themeSpacing(iconSizeUnits),
height: themeSpacing(iconSizeUnits),
borderRadius: "50%",
textDecoration: () => "none",
transformOrigin,
willChange: "transform",
transition: "transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1), background-color 150ms ease",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
"&:hover": { backgroundColor: (listener: Listener) => themeColor(listener, "increase-1") },
},
$: [tooltip({ content: item.label, placement: tooltipPlacement })],
_onMount: (node: ElementNode) => {
const element = node.domElement as HTMLElement | null;
if (element) iconRefs.push({ element });
},
_onRemove: (node: ElementNode) => {
const element = node.domElement as HTMLElement | null;
const index_ = iconRefs.findIndex((ref) => ref.element === element);
if (index_ >= 0) iconRefs.splice(index_, 1);
},
};
// Only attach the event handler prop when a click handler was actually
// provided — Domphy's event validation rejects an explicit `onClick:
// undefined`, unlike ordinary attribute props.
if (item.onClick) anchorElement.onClick = item.onClick;
return anchorElement;
}
/** Smoothstep falloff — smoother than linear, cheap to compute per frame. */
function smoothstep(t: number): number {
const clamped = Math.max(0, Math.min(1, t));
return clamped * clamped * (3 - 2 * clamped);
}
/**
* A floating macOS-style dock: a row of circular icon buttons that magnify
* as the cursor approaches them, with optional group separators and
* hover tooltips. Call with no arguments for a working 7-icon demo.
*/
function dock(props: DockProps = {}): DomphyElement<"nav"> {
const entries = props.items ?? DEFAULT_ITEMS;
const iconSizeUnits = props.iconSizeUnits ?? 10;
const magnification = props.magnification ?? 1.5;
const proximityMultiplier = props.proximityMultiplier ?? 3.5;
const anchor = props.anchor ?? "bottom";
const disableMagnification = props.disableMagnification ?? false;
const iconRefs: DockIconRef[] = [];
let animationFrame: number | null = null;
let pointerX: number | null = null;
const applyMagnification = () => {
animationFrame = null;
for (const ref of iconRefs) {
if (pointerX === null || disableMagnification) {
ref.element.style.transform = "";
continue;
}
const rect = ref.element.getBoundingClientRect();
if (rect.width === 0) continue;
const center = rect.left + rect.width / 2;
const distance = Math.abs(pointerX - center);
const threshold = rect.width * proximityMultiplier;
const falloff = smoothstep(1 - distance / threshold);
const scale = 1 + (magnification - 1) * falloff;
ref.element.style.transform = scale > 1.001 ? `scale(${scale.toFixed(3)})` : "";
}
};
const scheduleUpdate = () => {
if (animationFrame === null) animationFrame = requestAnimationFrame(applyMagnification);
};
const children: DomphyElement[] = entries.map((entry, index) =>
"separator" in entry
? dockSeparator(index)
: dockIconButton(entry, index, iconSizeUnits, anchor, iconRefs),
);
return {
nav: children,
ariaLabel: "Application dock",
dataTone: "shift-14",
style: {
position: "relative",
display: "flex",
alignItems: "center",
width: "fit-content",
marginInline: "auto",
gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
borderRadius: themeSpacing(999),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
outlineOffset: "-1px",
boxShadow: (listener: Listener) =>
`0 ${themeSpacing(2)} ${themeSpacing(10)} ${themeColor(listener, "shift-4")}`,
backdropFilter: (listener: Listener) => `blur(${themeSpacing(4)})`,
},
_onMount: (node: ElementNode) => {
const container = node.domElement as HTMLElement | null;
if (!container) return;
const handlePointerMove = (event: PointerEvent) => {
pointerX = event.clientX;
scheduleUpdate();
};
const handlePointerLeave = () => {
pointerX = null;
scheduleUpdate();
};
container.addEventListener("pointermove", handlePointerMove);
container.addEventListener("pointerleave", handlePointerLeave);
node.addHook("Remove", () => {
container.removeEventListener("pointermove", handlePointerMove);
container.removeEventListener("pointerleave", handlePointerLeave);
if (animationFrame !== null) cancelAnimationFrame(animationFrame);
});
},
};
}
export { dock };