A speed dial: one add button that opens a short line of secondary actions. The plus turns into a cross and four icon buttons spring out one after another, each with a caption, then fold back in reverse when the dial closes. A direction switch above the demo sends the same dial up, right, down or left so you can see every variant. The dial sits inside a dotted canvas frame and reports the last block added. It is positioned inside its frame, so it previews correctly and can be pinned to a corner in a real page.
// Adapted from Animata (https://animata.design/docs/fabs/speed-dial)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, FileText, ImagePlus, Link2, Plus, Table2 } from "lucide-react";
type Direction = "up" | "right" | "down" | "left";
const actions = [
{ key: "text", label: "Text block", icon: FileText },
{ key: "image", label: "Image", icon: ImagePlus },
{ key: "table", label: "Table", icon: Table2 },
{ key: "link", label: "Link card", icon: Link2 },
];
const directions: { key: Direction; label: string; icon: typeof ArrowUp }[] = [
{ key: "up", label: "Up", icon: ArrowUp },
{ key: "right", label: "Right", icon: ArrowRight },
{ key: "down", label: "Down", icon: ArrowDown },
{ key: "left", label: "Left", icon: ArrowLeft },
];
const STEP = 58; // distance between one action and the next, in pixels
const vector: Record<Direction, { x: number; y: number }> = {
up: { x: 0, y: -1 },
right: { x: 1, y: 0 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
};
const shell =
"flex h-12 w-12 items-center justify-center rounded-2xl border border-slate-200 bg-white text-slate-700 shadow-lg shadow-slate-900/10 transition-colors hover:bg-indigo-50 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2";
export default function SpeedDialActionButton() {
const reduce = useReducedMotion();
const menuId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const byUser = useRef(false);
const [open, setOpen] = useState(true);
const [direction, setDirection] = useState<Direction>("up");
const [added, setAdded] = useState<string[]>(["Text block"]);
useEffect(() => {
if (!open) return;
if (byUser.current) itemRefs.current[0]?.focus();
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
const count = actions.length;
const current = itemRefs.current.findIndex((node) => node === document.activeElement);
const go = (i: number) => itemRefs.current[(i + count) % count]?.focus();
// "Next" is the arrow that points the way the dial opens.
const next = { up: "ArrowUp", right: "ArrowRight", down: "ArrowDown", left: "ArrowLeft" }[direction];
const prev = { up: "ArrowDown", right: "ArrowLeft", down: "ArrowUp", left: "ArrowRight" }[direction];
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.key === "Tab") setOpen(false);
else if (event.key === "Home") {
event.preventDefault();
go(0);
} else if (event.key === "End") {
event.preventDefault();
go(count - 1);
} else if (event.key === next) {
event.preventDefault();
go(current + 1);
} else if (event.key === prev) {
event.preventDefault();
go(current - 1);
}
};
const v = vector[direction];
const vertical = direction === "up" || direction === "down";
return (
<section className="bg-slate-50 px-4 py-14 sm:px-6">
<div className="mx-auto max-w-3xl">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl">One button, four quick actions.</h2>
<p className="mt-2 max-w-md text-sm leading-relaxed text-slate-600">
The add button opens a short run of actions in whichever direction has room. Try each direction.
</p>
</div>
<div role="group" aria-label="Opening direction" className="flex gap-1 rounded-xl border border-slate-200 bg-white p-1">
{directions.map((d) => {
const Icon = d.icon;
const on = d.key === direction;
return (
<button
key={d.key}
type="button"
aria-pressed={on}
onClick={() => {
setDirection(d.key);
setOpen(true);
}}
className={`inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 ${
on ? "bg-indigo-600 text-white" : "text-slate-700 hover:bg-slate-100"
}`}
>
<Icon aria-hidden="true" className="h-4 w-4" />
{d.label}
</button>
);
})}
</div>
</div>
{/* Mock page canvas. The dial is absolutely placed inside it, not fixed to the window. */}
<div
className={`relative mt-6 flex h-[420px] overflow-hidden rounded-3xl border border-slate-200 bg-white ${
// Park the main button on the side opposite to where the dial opens.
{ up: "items-end justify-center pb-10", down: "items-start justify-center pt-20", right: "items-center justify-start pl-6", left: "items-center justify-end pr-6" }[direction]
}`}
style={{ backgroundImage: "radial-gradient(rgb(203 213 225) 1px, transparent 1px)", backgroundSize: "20px 20px" }}
>
<p aria-live="polite" className="absolute left-4 top-4 rounded-full bg-slate-900 px-3 py-1.5 text-xs font-medium text-white">
Last added: {added[added.length - 1]}
</p>
<div ref={rootRef} className="relative h-12 w-12">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="menu"
aria-controls={menuId}
aria-label={open ? "Close add menu" : "Open add menu"}
onClick={() => {
byUser.current = true;
setOpen((value) => !value);
}}
className="relative z-20 flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/30 transition-colors hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2"
>
<motion.span
aria-hidden="true"
className="flex"
initial={false}
animate={{ rotate: open ? 45 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 22 }}
>
<Plus className="h-5 w-5" strokeWidth={2.5} />
</motion.span>
</button>
<AnimatePresence>
{open && (
<ul id={menuId} role="menu" aria-label="Add a block" aria-orientation={vertical ? "vertical" : "horizontal"} onKeyDown={onKeyDown} className="absolute inset-0 z-10 m-0 list-none p-0">
{actions.map((action, index) => {
const Icon = action.icon;
const distance = (index + 1) * STEP;
return (
<motion.li
key={`${direction}-${action.key}`}
role="none"
className="absolute inset-0"
initial={reduce ? false : { x: 0, y: 0, scale: 0.4, opacity: 0 }}
animate={{ x: v.x * distance, y: v.y * distance, scale: 1, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { x: 0, y: 0, scale: 0.4, opacity: 0, transition: { duration: 0.15, delay: (actions.length - 1 - index) * 0.025 } }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 24, delay: index * 0.045 }}
>
<button
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
role="menuitem"
aria-label={`Add ${action.label.toLowerCase()}`}
onClick={() => {
setAdded((list) => [...list, action.label]);
close();
}}
className={shell}
>
<Icon aria-hidden="true" className="h-5 w-5" />
</button>
{/* Caption: beside the button for vertical dials, under it for horizontal ones. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute whitespace-nowrap font-medium text-slate-700 ${
vertical
? "left-full top-1/2 ml-3 -translate-y-1/2 rounded-md bg-white/90 px-2 py-1 text-xs shadow-sm ring-1 ring-slate-200"
: "left-1/2 top-full mt-2 -translate-x-1/2 text-center text-[11px] leading-tight"
}`}
>
{vertical ? action.label : action.label.split(" ")[0]}
</span>
</motion.li>
);
})}
</ul>
)}
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/speed-dial-action-button-that-opens-in-four-directions.json
It is a floating action button that reveals a few related actions when pressed. It keeps one clear primary button on screen while giving quick access to three to six secondary actions without opening a full menu.
Each direction is a unit vector such as x 0, y -1 for up. Every action is moved by that vector multiplied by its index and the step distance, so the same code produces all four layouts.
Yes. While the dial is open a `pointerdown` listener on the document closes it when the press lands outside the dial. The listener is removed again when the dial closes.
Each action button already has an `aria-label` such as Add image. The visible caption repeats that for sighted users, so it is marked `aria-hidden` to avoid reading the name twice.
Yes. Swap the inner `button` for an `a` with an `href`, and keep `role="menuitem"`, the label, the ref and the `shell` classes.
Adapted from Speed Dial in Animata, used under its open-source licence. Licences and credits
Related: Expanding Action Button That Morphs into a Quick Menu, Floating Toolbar Expanding from a Pill with Tool Options, Share Button That Expands into Icon Row.
More docks and floating menus or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.