"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export interface SpeedDialAction {
/** Stable, unique id. */
id: string;
/** Visible label and accessible name. */
label: string;
/** Icon inside the action button (decorative; the label names it). */
icon: React.ReactNode;
/** Runs when the action is chosen. */
onSelect?: () => void;
disabled?: boolean;
}
type Spring = { stiffness: number; damping: number; mass: number };
type Direction = "up" | "down" | "left" | "right";
type Corner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
export interface SpeedDialProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
actions: SpeedDialAction[];
/** Which way the actions open. The first action sits closest to the main button. */
direction?: Direction;
/** A straight stack, or a quarter-circle arc that sweeps from `direction` towards the inside of `corner`. */
layout?: "stack" | "arc";
/** "fixed" pins to the viewport, "absolute" to the nearest positioned parent, "static" stays in flow. */
position?: "fixed" | "absolute" | "static";
/** Corner used by fixed / absolute positioning; it also decides which side labels and arcs go to. */
corner?: Corner;
/** Distance from the corner in px (fixed / absolute only). */
offset?: number;
/** Show text labels beside the actions. Horizontal stacks show them on hover / focus only. */
showLabels?: boolean;
/** Space between actions in px. */
gap?: number;
/** Delay between actions as they open, in ms. Closing runs in reverse at half the delay. */
stagger?: number;
/** Spring for the actions and the main icon. */
spring?: Spring;
/** Degrees the main icon turns when open (45 turns a plus into a close cross). */
rotation?: number;
/** Main icon. Defaults to a plus. */
icon?: React.ReactNode;
/** Main button fill and focus ring colour. */
accent?: string;
/** Accessible name of the main button and the menu. */
label?: string;
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). Focus is not moved on mount. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Called after any action is chosen (in addition to the action's own onSelect). */
onAction?: (action: SpeedDialAction) => void;
}
const FAB = 56;
const ITEM = 44;
const ANGLE: Record<Direction, number> = { up: -90, down: 90, left: 180, right: 0 };
/** Keys that move away from / back towards the main button, per direction. */
const AWAY: Record<Direction, string> = { up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight" };
const TOWARD: Record<Direction, string> = { up: "ArrowDown", down: "ArrowUp", left: "ArrowRight", right: "ArrowLeft" };
const round = (n: number) => Math.round(n * 100) / 100;
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
);
}
/** Where each action sits relative to the centre of the main button, and which way its label points. */
function layoutActions(count: number, direction: Direction, layout: "stack" | "arc", corner: Corner, gap: number) {
const right = corner.endsWith("right");
const bottom = corner.startsWith("bottom");
const vertical = direction === "up" || direction === "down";
if (layout === "stack") {
const rad = (ANGLE[direction] * Math.PI) / 180;
const [ux, uy] = [Math.round(Math.cos(rad)), Math.round(Math.sin(rad))];
// Labels go towards the inside of the corner: beside vertical stacks, above / below horizontal ones.
const label = vertical ? ([right ? -1 : 1, 0] as const) : ([0, bottom ? -1 : 1] as const);
return Array.from({ length: count }, (_, i) => {
const d = FAB / 2 + gap + ITEM / 2 + i * (ITEM + gap);
return { x: ux * d, y: uy * d, lx: label[0], ly: label[1] };
});
}
// Arc: sweep 90° from `direction` towards the inside of the corner.
const sweepToward = vertical ? (right ? 180 : 0) : bottom ? -90 : 90;
const base = ANGLE[direction];
const sign = Math.sign(((sweepToward - base + 540) % 360) - 180) || 1;
const step = count > 1 ? 90 / (count - 1) : 0;
// Radius large enough that neighbouring actions never touch.
const minChord = ITEM + gap;
const radius = Math.max(FAB / 2 + gap + ITEM / 2 + 16, step ? minChord / (2 * Math.sin(((step / 2) * Math.PI) / 180)) : 0);
return Array.from({ length: count }, (_, i) => {
const rad = ((base + sign * i * step) * Math.PI) / 180;
const [cx, cy] = [Math.cos(rad), Math.sin(rad)];
return { x: round(cx * radius), y: round(cy * radius), lx: round(cx), ly: round(cy) };
});
}
/**
* A floating action button that fans out into labelled secondary actions with staggered springs.
* Implements the menu button pattern: aria-expanded trigger, role="menu" with role="menuitem" actions,
* arrow-key navigation, Escape to close (focus returns to the trigger) and click-outside dismissal.
*/
export function SpeedDial({
actions,
direction = "up",
layout = "stack",
position = "fixed",
corner = "bottom-right",
offset = 24,
showLabels = true,
gap = 12,
stagger = 40,
spring = { stiffness: 480, damping: 30, mass: 0.8 },
rotation = 45,
icon,
accent = "#FF7448",
label = "Quick actions",
open: openProp,
defaultOpen = false,
onOpenChange,
onAction,
className = "",
style,
...props
}: SpeedDialProps) {
const uid = React.useId();
const menuId = `${uid}-menu`;
const triggerId = `${uid}-trigger`;
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
/** Index to focus once the actions have mounted (set only by user interaction). */
const focusOnOpen = React.useRef<number | null>(null);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isOpen = openProp ?? internalOpen;
const count = actions.length;
const positions = layoutActions(count, direction, layout, corner, gap);
const horizontalStack = layout === "stack" && (direction === "left" || direction === "right");
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
const enabledIndexes = actions.flatMap((a, i) => (a.disabled ? [] : [i]));
const firstEnabled = enabledIndexes[0] ?? -1;
const lastEnabled = enabledIndexes[enabledIndexes.length - 1] ?? -1;
const openMenu = (focusIndex: number) => {
focusOnOpen.current = focusIndex;
setOpen(true);
};
const close = (returnFocus: boolean) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
};
const choose = (action: SpeedDialAction) => {
if (action.disabled) return;
action.onSelect?.();
onAction?.(action);
close(true);
};
// Move focus into the menu once the actions exist (only after a user opened it).
React.useEffect(() => {
if (!isOpen || focusOnOpen.current === null) return;
const index = focusOnOpen.current;
focusOnOpen.current = null;
if (index >= 0) itemRefs.current[index]?.focus({ preventScroll: true });
}, [isOpen]);
const outsidePointer = React.useEffectEvent((event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return;
close(false);
});
React.useEffect(() => {
if (!isOpen) return;
const onDown = (event: PointerEvent) => outsidePointer(event);
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [isOpen]);
const step = (from: number, delta: 1 | -1) => {
for (let i = 1; i <= count; i++) {
const next = (from + delta * i + count * 2) % count;
if (!actions[next].disabled) return next;
}
return from;
};
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
const current = itemRefs.current.findIndex((el) => el === document.activeElement);
let next: number | null = null;
if (event.key === AWAY[direction]) next = step(current, 1);
else if (event.key === TOWARD[direction]) next = step(current, -1);
else if (event.key === "ArrowDown" || event.key === "ArrowRight") next = step(current, 1);
else if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = step(current, -1);
else if (event.key === "Home") next = firstEnabled;
else if (event.key === "End") next = lastEnabled;
else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close(true);
return;
} else if (event.key === "Tab") {
// Leaving the menu closes it; focus continues to the next element naturally.
setOpen(false);
return;
}
if (next === null || next < 0) return;
event.preventDefault();
itemRefs.current[next]?.focus({ preventScroll: true });
};
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
openMenu(event.key === TOWARD[direction] ? lastEnabled : firstEnabled);
} else if (event.key === "Escape" && isOpen) {
event.preventDefault();
close(true);
}
};
const handleTriggerClick = () => {
if (isOpen) close(false);
else openMenu(firstEnabled);
};
const springTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const fade = reduceMotion ? { duration: 0 } : { duration: 0.16 };
const right = corner.endsWith("right");
const bottom = corner.startsWith("bottom");
const placement: React.CSSProperties =
position === "static"
? {}
: { [bottom ? "bottom" : "top"]: offset, [right ? "right" : "left"]: offset };
return (
<div
ref={rootRef}
className={`${position === "fixed" ? "fixed z-40" : position === "absolute" ? "absolute z-10" : "relative inline-block"} ${className}`}
style={{ ["--sd-accent" as string]: accent, width: FAB, height: FAB, ...placement, ...style }}
{...props}
>
<ul
id={menuId}
role={isOpen ? "menu" : undefined}
aria-labelledby={isOpen ? triggerId : undefined}
aria-orientation={isOpen ? (direction === "up" || direction === "down" ? "vertical" : "horizontal") : undefined}
aria-hidden={isOpen ? undefined : true}
onKeyDown={handleMenuKeyDown}
className={`absolute left-1/2 top-1/2 m-0 list-none p-0 ${isOpen ? "" : "pointer-events-none"}`}
>
<AnimatePresence>
{isOpen &&
actions.map((action, i) => {
const p = positions[i];
const labelShift = ITEM / 2 + 10;
return (
<motion.li
key={action.id}
role="none"
className="group absolute"
style={{ left: -ITEM / 2, top: -ITEM / 2, width: ITEM, height: ITEM }}
initial={reduceMotion ? { opacity: 0 } : { x: 0, y: 0, scale: 0.4, opacity: 0 }}
animate={{
x: p.x,
y: p.y,
scale: 1,
opacity: 1,
transition: { default: { ...springTransition, delay: reduceMotion ? 0 : (i * stagger) / 1000 }, opacity: fade },
}}
exit={{
x: reduceMotion ? p.x : 0,
y: reduceMotion ? p.y : 0,
scale: reduceMotion ? 1 : 0.4,
opacity: 0,
transition: {
default: { ...springTransition, delay: reduceMotion ? 0 : ((count - 1 - i) * stagger) / 2000 },
opacity: reduceMotion ? fade : { duration: 0.14, delay: ((count - 1 - i) * stagger) / 2000 + 0.06 },
},
}}
>
<button
ref={(el) => {
itemRefs.current[i] = el;
}}
type="button"
role="menuitem"
tabIndex={-1}
disabled={action.disabled}
aria-disabled={action.disabled || undefined}
onClick={() => choose(action)}
className="relative grid size-full place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-700 shadow-[0_1px_2px_rgb(0_0_0/0.06),0_6px_16px_-6px_rgb(0_0_0/0.18)] outline-hidden transition-colors duration-150 hover:bg-zinc-50 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sd-accent)] disabled:cursor-not-allowed disabled:opacity-45 dark:border-white/10 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:hover:text-white [&_svg]:size-[18px]"
>
<span aria-hidden="true" className="inline-flex">
{action.icon}
</span>
{/* The label lives inside the menuitem so it is the accessible name, then sits beside it visually. */}
<span
className={
showLabels
? `pointer-events-none absolute whitespace-nowrap rounded-md border border-zinc-900/10 bg-white/95 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-700 shadow-sm dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-200 ${
horizontalStack
? "opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 motion-reduce:transition-none"
: ""
}`
: "sr-only"
}
style={
showLabels
? {
left: `calc(50% + ${round(p.lx * labelShift)}px)`,
top: `calc(50% + ${round(p.ly * labelShift)}px)`,
transform: `translate(${round(-50 + p.lx * 50)}%, ${round(-50 + p.ly * 50)}%)`,
}
: undefined
}
>
{action.label}
</span>
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
<button
ref={triggerRef}
id={triggerId}
type="button"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={menuId}
onClick={handleTriggerClick}
onKeyDown={handleTriggerKeyDown}
className="relative grid size-full place-items-center rounded-full bg-[var(--sd-accent)] text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.12),0_10px_24px_-8px_rgb(0_0_0/0.35)] outline-hidden transition-transform duration-150 hover:scale-[1.04] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--sd-accent)] active:scale-95 motion-reduce:transition-none [&_svg]:size-6"
>
<motion.span
aria-hidden="true"
className="inline-flex"
initial={false}
animate={{ rotate: isOpen ? rotation : 0 }}
transition={springTransition}
>
{icon ?? <PlusIcon />}
</motion.span>
</button>
</div>
);
}