Speed Dial FAB
by Dax · tsx · 56 lines
A Material-style floating action button that fans out a ring of action buttons on click. Each action slides and fades in with a stagger. Tap outside or press Escape to collapse.
fabspeed-dialactionsframer-motion
56 lines
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, X } from "lucide-react";
type Action = { icon: React.ReactNode; label: string; onClick: () => void };
export function SpeedDial({ actions }: { actions: Action[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function close(e: MouseEvent) {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
}
function esc(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", close);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("mousedown", close);
document.removeEventListener("keydown", esc);
};
}, []);
return (
<div ref={ref} className="fixed bottom-6 right-6 z-50 flex flex-col-reverse items-center gap-3">
<button
onClick={() => setOpen(!open)}
className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition hover:bg-blue-700"
>
<motion.span animate={{ rotate: open ? 45 : 0 }} transition={{ duration: 0.2 }}>
{open ? <X size={24} /> : <Plus size={24} />}
</motion.span>
</button>
<AnimatePresence>
{open && actions.map((action, i) => (
<motion.button
key={action.label}
initial={{ opacity: 0, y: 16, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.9 }}
transition={{ delay: i * 0.05, duration: 0.2, ease: "easeOut" }}
onClick={() => { action.onClick(); setOpen(false); }}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-neutral-700 shadow-md ring-1 ring-neutral-200 transition hover:bg-neutral-50"
title={action.label}
>
{action.icon}
</motion.button>
))}
</AnimatePresence>
</div>
);
}