Speed Dial
FAB that expands into a row of actions on click. CSS stagger, no hover gate.
Installation
CLI
pnpm dlx shadcn@latest add https://animata.design/r/fabs/speed-dial.json
Manual
Install dependencies
npm install lucide-reactRun the following command
It will create speed-dial.tsx and the co-located speed-dial.css inside components/animata/fabs.
mkdir -p components/animata/fabs && touch components/animata/fabs/speed-dial.tsx components/animata/fabs/speed-dial.cssPaste the code
@layer components {
@keyframes speed-dial-in-up {
from {
opacity: 0;
transform: translateY(10px) scale(0.82);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes speed-dial-in-down {
from {
opacity: 0;
transform: translateY(-10px) scale(0.82);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes speed-dial-in-left {
from {
opacity: 0;
transform: translateX(10px) scale(0.82);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes speed-dial-in-right {
from {
opacity: 0;
transform: translateX(-10px) scale(0.82);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
[data-direction="up"] .speed-dial-item {
animation-name: speed-dial-in-up;
}
[data-direction="down"] .speed-dial-item {
animation-name: speed-dial-in-down;
}
[data-direction="left"] .speed-dial-item {
animation-name: speed-dial-in-left;
}
[data-direction="right"] .speed-dial-item {
animation-name: speed-dial-in-right;
}
.speed-dial-item {
animation-duration: 120ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
animation-fill-mode: both;
animation-delay: calc(12ms + var(--i) * 22ms);
}
@media (prefers-reduced-motion: reduce) {
.speed-dial-item {
animation: none;
opacity: 1;
transform: none;
}
.speed-dial-trigger-icon {
transition: none;
}
}
}"use client";
import { Plus } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import "./speed-dial.css";
type Direction = "up" | "down" | "left" | "right";
interface SpeedDialAction {
icon: React.ReactNode;
label: string;
key: string;
action: () => void;
}
interface SpeedDialProps extends React.HTMLAttributes<HTMLDivElement> {
direction?: Direction;
actionButtons: SpeedDialAction[];
/** Accessible name for the main toggle when closed. */
triggerLabel?: string;
}
const shellClass =
"flex size-11 shrink-0 touch-manipulation items-center justify-center rounded-xl border border-border/80 bg-background/85 text-foreground shadow-lg backdrop-blur-xl transition-colors hover:bg-accent/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97]";
const menuAnchorClass: Record<Direction, string> = {
up: "bottom-full left-1/2 mb-2 -translate-x-1/2 origin-bottom",
down: "top-full left-1/2 mt-2 -translate-x-1/2 origin-top",
left: "right-full top-1/2 mr-2 -translate-y-1/2 origin-right",
right: "left-full top-1/2 ml-2 -translate-y-1/2 origin-left",
};
const menuLayoutClass: Record<Direction, string> = {
up: "flex flex-col-reverse items-center gap-2.5",
down: "flex flex-col items-center gap-2.5",
left: "flex flex-row-reverse items-center gap-2.5",
right: "flex flex-row items-center gap-2.5",
};
function arrowKeysForDirection(direction: Direction) {
if (direction === "up") return { prev: "ArrowDown", next: "ArrowUp" };
if (direction === "down") return { prev: "ArrowUp", next: "ArrowDown" };
if (direction === "left") return { prev: "ArrowRight", next: "ArrowLeft" };
return { prev: "ArrowLeft", next: "ArrowRight" };
}
export default function SpeedDial({
direction = "right",
actionButtons,
triggerLabel = "Open actions menu",
className,
...props
}: SpeedDialProps) {
const menuId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [open, setOpen] = useState(false);
const close = useCallback(() => {
setOpen(false);
triggerRef.current?.focus();
}, []);
const toggle = useCallback(() => {
setOpen((value) => !value);
}, []);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
useEffect(() => {
if (!open) return;
itemRefs.current[0]?.focus();
}, [open]);
const focusItem = (index: number) => {
const count = actionButtons.length;
if (count === 0) return;
const next = (index + count) % count;
itemRefs.current[next]?.focus();
};
const onMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
const { prev, next } = arrowKeysForDirection(direction);
const activeIndex = itemRefs.current.findIndex((node) => node === document.activeElement);
switch (event.key) {
case "Escape":
event.preventDefault();
close();
break;
case "Tab":
setOpen(false);
break;
case "Home":
event.preventDefault();
itemRefs.current[0]?.focus();
break;
case "End":
event.preventDefault();
itemRefs.current[actionButtons.length - 1]?.focus();
break;
default:
if (event.key === prev) {
event.preventDefault();
focusItem(activeIndex <= 0 ? actionButtons.length - 1 : activeIndex - 1);
} else if (event.key === next) {
event.preventDefault();
focusItem(activeIndex < 0 ? 0 : activeIndex + 1);
}
}
};
return (
<div
ref={rootRef}
className={cn("relative inline-flex", className)}
data-direction={direction}
{...props}
>
<button
ref={triggerRef}
type="button"
className={cn(shellClass, "relative z-20")}
aria-expanded={open}
aria-haspopup="menu"
aria-controls={menuId}
aria-label={open ? "Close actions menu" : triggerLabel}
onClick={toggle}
>
<span
aria-hidden="true"
className={cn(
"speed-dial-trigger-icon flex items-center justify-center transition-transform duration-150 ease-out",
open && "rotate-45",
)}
>
<Plus className="size-5" strokeWidth={2.25} />
</span>
</button>
{open ? (
<ul
id={menuId}
role="menu"
aria-orientation={direction === "up" || direction === "down" ? "vertical" : "horizontal"}
onKeyDown={onMenuKeyDown}
className={cn(
"absolute z-10 m-0 list-none p-0",
menuAnchorClass[direction],
menuLayoutClass[direction],
)}
>
{actionButtons.map((action, index) => (
<li
key={action.key}
role="none"
className="speed-dial-item list-none"
style={{ "--i": index } as React.CSSProperties}
>
<button
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
role="menuitem"
className={shellClass}
aria-label={action.label}
onClick={() => {
action.action();
close();
}}
>
<span className="sr-only">{action.label}</span>
<span
aria-hidden="true"
className="flex size-5 items-center justify-center [&>svg]:size-5"
>
{action.icon}
</span>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}How it works
Click opens the menu; hover doesn't. That matters on touch devices. The trigger is a <button> with aria-expanded, aria-haspopup="menu", and aria-controls pointing at the action list, which only mounts while open.
Each action is a role="menuitem" at size-11 (44×44 px). Labels live in aria-label and an sr-only span, not beside the icon. The action list is positioned absolutely (left-full, bottom-full, and so on relative to direction) so the FAB doesn't jump when items appear.
Motion is CSS only: direction-specific @keyframes in the co-located speed-dial.css, 120ms per item, staggered with animation-delay: calc(12ms + var(--i) * 22ms). No Motion package. The plus icon rotates 45° via transition-transform over 150ms. Reduced-motion users get the layout without the keyframes.
Keyboard: arrows move along the axis you picked with direction; Home and End hit the first and last item; Escape or an outside tap closes and refocuses the trigger.
Credits
Built by Sabin Shrestha