FAB Button
Original · freeA floating action button that expands into a speed-dial.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/btn-fab.json"use client";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Action = {
id: string;
label: string;
hint?: string;
icon: ReactNode;
tone: string;
};
const PlusIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" aria-hidden="true" className="h-6 w-6">
<path d="M12 5v14M5 12h14" />
</svg>
);
const EditIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
const PhotoIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<rect x="3" y="3" width="18" height="18" rx="3" />
<circle cx="8.5" cy="8.5" r="1.6" />
<path d="m21 15-5-5L5 21" />
</svg>
);
const NoteIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<path d="M4 4h16v12l-4 4H4Z" />
<path d="M16 20v-4h4" />
<path d="M8 9h8M8 13h5" />
</svg>
);
const MicIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v4" />
</svg>
);
const ShareIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4" />
</svg>
);
const ACTIONS: Action[] = [
{ id: "note", label: "New note", hint: "N", icon: <NoteIcon />, tone: "text-sky-600 dark:text-sky-400" },
{ id: "photo", label: "Upload photo", hint: "U", icon: <PhotoIcon />, tone: "text-emerald-600 dark:text-emerald-400" },
{ id: "voice", label: "Record voice", hint: "R", icon: <MicIcon />, tone: "text-rose-600 dark:text-rose-400" },
{ id: "post", label: "Write post", hint: "W", icon: <EditIcon />, tone: "text-amber-600 dark:text-amber-500" },
];
function SpeedDial({
actions,
accent,
align,
label,
}: {
actions: Action[];
accent: string;
align: "start" | "end";
label: string;
}) {
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState<string | null>(null);
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const fabRef = useRef<HTMLButtonElement>(null);
const menuId = useId();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
fabRef.current?.focus();
}
};
const onClick = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClick);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClick);
};
}, [open]);
const dur = reduce ? 0 : 0.22;
return (
<div
ref={rootRef}
className={`relative flex flex-col-reverse ${align === "end" ? "items-end" : "items-start"}`}
>
<AnimatePresence>
{open && (
<motion.ul
id={menuId}
role="menu"
aria-label={label}
initial="hide"
animate="show"
exit="hide"
variants={{
show: { transition: { staggerChildren: reduce ? 0 : 0.045, delayChildren: 0.02 } },
hide: { transition: { staggerChildren: reduce ? 0 : 0.03, staggerDirection: -1 } },
}}
className={`mb-3 flex flex-col gap-2.5 ${align === "end" ? "items-end" : "items-start"}`}
>
{actions.map((a) => (
<motion.li
key={a.id}
role="none"
variants={{
hide: { opacity: 0, y: reduce ? 0 : 12, scale: reduce ? 1 : 0.9 },
show: { opacity: 1, y: 0, scale: 1 },
}}
transition={{ duration: dur, ease: [0.16, 1, 0.3, 1] }}
className={`flex items-center gap-3 ${align === "end" ? "flex-row" : "flex-row-reverse"}`}
>
<span
className={`select-none whitespace-nowrap rounded-lg bg-slate-900/90 px-2.5 py-1 text-xs font-medium text-white shadow-sm ring-1 ring-black/5 dark:bg-white/90 dark:text-slate-900 ${
align === "end" ? "order-first" : ""
}`}
>
{a.label}
{a.hint && (
<kbd className="ml-1.5 rounded bg-white/20 px-1 font-mono text-[10px] dark:bg-slate-900/15">
{a.hint}
</kbd>
)}
</span>
<button
type="button"
role="menuitem"
onClick={() => {
setPicked(a.id);
setOpen(false);
fabRef.current?.focus();
}}
className={`grid h-11 w-11 place-items-center rounded-full bg-white ${a.tone} shadow-md ring-1 ring-slate-200 transition-transform duration-150 hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:scale-95 dark:bg-slate-800 dark:ring-slate-700 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900`}
aria-label={a.label}
>
{a.icon}
</button>
</motion.li>
))}
</motion.ul>
)}
</AnimatePresence>
<button
ref={fabRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={menuId}
aria-label={open ? "Close quick actions" : "Open quick actions"}
className={`group relative grid h-14 w-14 place-items-center rounded-full text-white shadow-lg shadow-black/20 ring-1 ring-white/20 transition-[transform,box-shadow] duration-200 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:scale-95 dark:focus-visible:ring-offset-slate-900 ${accent}`}
>
{!reduce && open && (
<span className="bfab-ripple pointer-events-none absolute inset-0 rounded-full ring-2 ring-current opacity-40" />
)}
<motion.span
animate={{ rotate: open ? 135 : 0 }}
transition={{ duration: dur, ease: [0.16, 1, 0.3, 1] }}
className="grid place-items-center"
>
<PlusIcon />
</motion.span>
</button>
<div aria-live="polite" className="sr-only">
{picked ? `${actions.find((a) => a.id === picked)?.label} selected` : ""}
</div>
</div>
);
}
export default function BtnFab() {
const [labelled, setLabelled] = useState(false);
return (
<section className="relative w-full bg-slate-50 px-6 py-20 dark:bg-slate-950">
<style>{`
@keyframes bfab-ripple-kf {
0% { transform: scale(1); opacity: 0.45; }
100% { transform: scale(1.9); opacity: 0; }
}
.bfab-ripple { animation: bfab-ripple-kf 1.6s ease-out infinite; }
@keyframes bfab-float-kf {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.bfab-float { animation: bfab-float-kf 4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.bfab-ripple, .bfab-float { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-14 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
<ShareIcon />
Floating action button
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
Speed-dial FAB
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
One tap fans out a stack of quick actions. Fully keyboard driven, closes on Escape or an
outside click, and respects reduced motion.
</p>
</header>
<div className="grid gap-5 sm:grid-cols-3">
<div className="relative flex min-h-[22rem] flex-col justify-between overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">Indigo, upward</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Default anchored bottom-right.
</p>
</div>
<div className="flex justify-end">
<SpeedDial actions={ACTIONS} accent="bg-indigo-600 hover:bg-indigo-500 focus-visible:ring-indigo-600" align="end" label="Quick actions" />
</div>
</div>
<div className="relative flex min-h-[22rem] flex-col justify-between overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">Emerald, left aligned</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Labels flip to the right side.
</p>
</div>
<div className="flex justify-start">
<SpeedDial actions={ACTIONS.slice(0, 3)} accent="bg-emerald-600 hover:bg-emerald-500 focus-visible:ring-emerald-600" align="start" label="Compose actions" />
</div>
</div>
<div className="relative flex min-h-[22rem] flex-col justify-between overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">Violet, idle pulse</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Gentle float draws the eye.
</p>
</div>
<div className="flex justify-end">
<div className="bfab-float">
<SpeedDial actions={ACTIONS} accent="bg-violet-600 hover:bg-violet-500 focus-visible:ring-violet-600" align="end" label="Create menu" />
</div>
</div>
</div>
</div>
<div className="mt-6 flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white p-5 text-center dark:border-slate-800 dark:bg-slate-900">
<button
type="button"
onClick={() => setLabelled((v) => !v)}
aria-pressed={labelled}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium ring-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
labelled
? "bg-slate-900 text-white ring-slate-900 focus-visible:ring-slate-900 dark:bg-white dark:text-slate-900 dark:ring-white dark:focus-visible:ring-white"
: "bg-slate-100 text-slate-700 ring-slate-200 hover:bg-slate-200 focus-visible:ring-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-700"
}`}
>
<span className={`h-2 w-2 rounded-full ${labelled ? "bg-emerald-400" : "bg-slate-400"}`} />
Extended label {labelled ? "on" : "off"}
</button>
<ExtendedFab extended={labelled} />
</div>
</div>
</section>
);
}
function ExtendedFab({ extended }: { extended: boolean }) {
const [saving, setSaving] = useState(false);
const [done, setDone] = useState(false);
const run = () => {
if (saving) return;
setDone(false);
setSaving(true);
window.setTimeout(() => {
setSaving(false);
setDone(true);
}, 1200);
};
return (
<button
type="button"
onClick={run}
disabled={saving}
aria-label="Save draft"
className="inline-flex items-center gap-2.5 rounded-full bg-rose-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-600/25 transition-[transform,background-color] duration-200 hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-95 disabled:cursor-not-allowed disabled:opacity-70 dark:focus-visible:ring-offset-slate-900"
>
{saving ? (
<svg viewBox="0 0 24 24" className="h-5 w-5 animate-spin" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
) : done ? (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m20 6-11 11-5-5" />
</svg>
) : (
<NoteIcon />
)}
{extended && <span>{saving ? "Saving…" : done ? "Saved" : "Save draft"}</span>}
</button>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Soft Set Button
OriginalSoft, tinted buttons across semantic colours.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

