The problem
A floating action button is the one control a thumb can always reach, and it says nothing about what it does. A menu answers that, but a centred or dropped panel puts half its options outside the arc the thumb can cover — so the affordance that made the button good is lost the moment it is used. Fanning the actions out of the button keeps the origin visible and every option in reach.
Use cases
- A mobile create or upload button with three to six item types
- A corner-anchored quick action set in a media or files app
- Any thumb-zone control where the options must stay reachable one-handed
Not for: desktop-first layouts with room for an inline row — use
split-actions. Not for more than six actions: the stack grows taller than the
thumb arc, which defeats the point. Not for grids of options where scanning
matters more than reach — use create-menu.
Installation
npm install motion lucide-reactThen copy SpeedDial.tsx. It depends only on @/lib/cn.
Usage
import SpeedDial from "@/patterns/speed-dial/SpeedDial";
import { Music, Video } from "lucide-react";
<SpeedDial
triggerLabel="Add media"
actions={[
{
id: "music",
label: "Music",
icon: <Music />,
onSelect: () => addMusic(),
},
{
id: "video",
label: "Video",
icon: <Video />,
onSelect: () => addVideo(),
},
]}
/>;The stack is absolutely positioned above the trigger, so give the container headroom — the component does not reserve it, because how much depends on where you anchor it.
API
| Prop | Type | Default | Description |
|---|---|---|---|
actions | readonly SpeedDialAction[] | — | Three to six. Empty renders nothing. |
triggerLabel | string | "Actions" | Accessible name for the trigger and the menu. |
defaultOpen | boolean | false | Uncontrolled initial state. |
open | boolean | uncontrolled | Controlled state. Pass with onOpenChange. |
onOpenChange | (open: boolean) => void | — | Fires on every toggle. |
className | string | — | Merged onto the root. |
SpeedDialAction: { id: string; label: string; icon: ReactNode; onSelect: () => void }.
Keyboard
| Key | Action |
|---|---|
Enter / Space | Open the stack, or run the focused action |
↑ | Move toward the top of the stack |
↓ | Move back toward the trigger |
Home / End | Furthest or nearest card |
Escape | Close and return focus to the trigger |
Arrows follow the visual column rather than DOM order, so ↑ always moves up
the screen.
Accessibility
The actions did nothing. They were cursor-pointer divs with no click
handler — the stack opened, looked complete, and could not be used by anyone,
mouse included. They are real buttons with an onSelect callback now.
The trigger was an icon-only button with no accessible name, so it announced
as "button". It now has a name, aria-haspopup="menu" and aria-expanded,
and it controls a labelled role="menu".
Opening focuses the card nearest the trigger — the one that unfolds first and
the one a thumb reaches without stretching. Closing returns focus to the
trigger. Escape closes from anywhere inside.
Under prefers-reduced-motion the fan collapses to a plain cross-fade: no
travel, no rotation, and the trigger does not spin. The stack still opens, so
nothing is lost.
Known limitation: the stack opens upward only. Anchored in a top corner it would open off-screen. Flipping it needs collision detection against the viewport, which is a positioning concern this pattern deliberately does not own — wrap it in a positioning primitive if you need that.
Performance
Animates transform and opacity only. Offset and rotation are derived from
each card's distance from the trigger, so the geometry generalises to any
count rather than being hardcoded for five.
The stagger is 30ms per card, sequenced from the trigger outward, so a five-card stack settles in 320ms — inside the budget, and it is what makes the stack read as unfolding rather than appearing.
The trigger stays mounted throughout, unlike the other disclosure patterns here, so there is no shared-layout work and no focus to recover from an unmount.
Source
The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/cn";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface SpeedDialAction {
id: string;
label: string;
icon: React.ReactNode;
onSelect: () => void;
}
export interface SpeedDialProps extends Omit<
React.ComponentPropsWithoutRef<"div">,
"onSelect"
> {
/** Three to six. Beyond that the stack is taller than a thumb can reach. */
actions: readonly SpeedDialAction[];
/** Accessible name for the trigger. @default "Actions" */
triggerLabel?: string;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/* -------------------------------------------------------------------------- */
/* Motion — geometry and timing kept verbatim from the original */
/* -------------------------------------------------------------------------- */
/**
* Each card is pushed further left and tilted further as it gets further from
* the trigger, so the stack reads as having unfolded out of the button rather
* than as a list that appeared above it. `distance` is measured from the
* trigger, so the nearest card barely moves and animates first.
*/
const X_PER_STEP = -32;
const X_BASE = -4;
const ROTATE_PER_STEP = -4;
const ROTATE_BASE = -4;
const STAGGER = 0.03;
const DURATION = 0.2;
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* A corner-anchored button that fans its actions out above itself.
*
* A floating action button is reachable one-handed but says nothing about what
* it does; a menu answers that but arrives as a detached panel. Fanning the
* cards out of the button keeps the origin visible, and staying anchored to
* the corner keeps every option inside the thumb arc — which a centred menu
* does not.
*/
export function SpeedDial({
actions,
triggerLabel = "Actions",
defaultOpen = false,
open: controlledOpen,
onOpenChange,
className,
...rest
}: SpeedDialProps) {
const reduce = useReducedMotion();
const uid = useId();
const menuId = `${uid}-menu`;
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const interacted = useRef(false);
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolled;
const [activeIndex, setActiveIndex] = useState(actions.length - 1);
const setOpen = useCallback(
(next: boolean) => {
interacted.current = true;
// Focus the card nearest the trigger — it is the one that unfolds first
// and the one a thumb reaches without stretching. Set here rather than
// in the effect below, because opening is an event.
if (next) setActiveIndex(actions.length - 1);
if (!isControlled) setUncontrolled(next);
onOpenChange?.(next);
},
[actions.length, isControlled, onOpenChange],
);
/*
* The trigger stays mounted here, unlike the other disclosure patterns, so
* focus is not destroyed — but it still has to move, or opening a menu
* leaves a keyboard user with nothing to act on.
*/
useEffect(() => {
if (!interacted.current) return;
if (open) itemRefs.current[actions.length - 1]?.focus();
else triggerRef.current?.focus();
}, [open, actions.length]);
const focusItem = useCallback((index: number) => {
setActiveIndex(index);
itemRefs.current[index]?.focus();
}, []);
/** Arrows follow the visual column: up moves toward the top of the stack. */
const handleItemKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const count = actions.length;
switch (event.key) {
case "Escape":
event.preventDefault();
setOpen(false);
return;
case "ArrowUp":
event.preventDefault();
focusItem((index - 1 + count) % count);
return;
case "ArrowDown":
event.preventDefault();
focusItem((index + 1) % count);
return;
case "Home":
event.preventDefault();
focusItem(0);
return;
case "End":
event.preventDefault();
focusItem(count - 1);
return;
default:
return;
}
},
[actions.length, focusItem, setOpen],
);
if (actions.length === 0) return null;
return (
<div
className={cn("relative flex justify-center", className)}
onKeyDown={(event) => {
if (event.key === "Escape" && open) setOpen(false);
}}
{...rest}
>
<div
id={menuId}
role="menu"
aria-label={triggerLabel}
className="absolute bottom-[calc(100%+16px)] flex flex-col items-center gap-2.5"
>
<AnimatePresence>
{open &&
actions.map((action, index) => {
// Distance from the trigger: the last card sits closest to it.
const distance = actions.length - 1 - index;
const x = distance * X_PER_STEP + X_BASE;
const rotate = distance * ROTATE_PER_STEP + ROTATE_BASE;
return (
<motion.button
key={action.id}
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => {
action.onSelect();
setOpen(false);
}}
onKeyDown={(event) => handleItemKeyDown(event, index)}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 20, x: 0, rotate: 0 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, y: 0, x, rotate }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: 20, x: 0, rotate: 0 }
}
transition={
reduce
? { duration: 0.01 }
: { duration: DURATION, delay: distance * STAGGER }
}
className={cn(
"focus-ring flex cursor-pointer items-center gap-3",
"rounded-2xl bg-surface px-4 py-2.5 shadow-resting",
"font-semibold whitespace-nowrap text-content-muted",
"hover:text-content",
)}
>
<span aria-hidden="true" className="[&>svg]:size-5">
{action.icon}
</span>
{action.label}
</motion.button>
);
})}
</AnimatePresence>
</div>
<motion.button
ref={triggerRef}
type="button"
aria-label={triggerLabel}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => setOpen(!open)}
// Grows rather than shrinks on press. Unusual, and kept: it reads as
// the button swelling to release the stack.
whileTap={reduce ? undefined : { scale: 1.05 }}
animate={{ rotate: open && !reduce ? 45 : 0 }}
className={cn(
"focus-ring relative z-10 grid size-12 place-items-center",
"rounded-full bg-surface text-content-muted shadow-resting",
)}
>
<Plus className="size-6" aria-hidden="true" />
</motion.button>
</div>
);
}
export default SpeedDial;
Technologies
| Framework | React 19 |
| Motion | Motion 12 (motion/react) |
| Styling | Tailwind CSS v4 |
| Icons | lucide-react |
| Types | TypeScript 5.9, strict |
Credits
Original design by Dmitry Elisov. Renamed from Fan Menu, which described the shape rather than the job. Rebuilt with a props API, working actions, a keyboard-operable menu, focus hand-off, and reduced-motion handling. The fan geometry, the 30ms stagger and the trigger's 45° turn are unchanged — including the tap that grows rather than shrinks.