A floating action button that expands a vertical stack of labeled mini-actions with a 40ms stagger, container scrim, arrow-key traversal, and Escape to close.
npx shadcn@latest add @paragon/fab-stack"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FabStackAction {
/** Visible label pill next to the mini action. */
label: string;
icon: React.ReactNode;
onSelect?: () => void;
}
export interface FabStackProps
extends Omit<React.ComponentProps<"div">, "children"> {
actions: FabStackAction[];
/** Icon in the main button. Rotates 45° while open. Defaults to a plus. */
icon?: React.ReactNode;
/** Accessible name for the main button. */
label?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Corner of the nearest `position: relative` ancestor. */
position?: "bottom-right" | "bottom-left";
/** Dim the surrounding container while open. */
scrim?: boolean;
/** Closes the stack after an action is selected. */
closeOnSelect?: boolean;
/** Disables the stagger/rise motion; items fade instantly. */
static?: boolean;
}
/**
* A floating action button that expands a vertical stack of labeled
* mini-actions with a 40ms stagger (rise + blur enter, quick uniform
* exit). A scrim dims the surrounding container, Escape or an outside
* click closes, and focus returns to the main button. Position it inside
* a `relative` container.
*/
export function FabStack({
actions,
icon,
label = "Actions",
open: controlledOpen,
defaultOpen = false,
onOpenChange,
position = "bottom-right",
scrim = true,
closeOnSelect = true,
static: isStatic = false,
className,
...props
}: FabStackProps) {
const reducedMotion = useReducedMotion();
const instant = isStatic || !!reducedMotion;
const stackId = React.useId();
const rootRef = React.useRef<HTMLDivElement>(null);
const mainRef = React.useRef<HTMLButtonElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
// Escape closes and hands focus back to the main button.
React.useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
setOpen(false);
mainRef.current?.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, setOpen]);
// Arrow keys walk the stack: main button ↔ mini-actions.
const handleRootKeyDown = (event: React.KeyboardEvent) => {
if (!open || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
const root = rootRef.current;
if (!root) return;
const items = Array.from(
root.querySelectorAll<HTMLButtonElement>("[data-fab-stack-item]"),
);
const order = [mainRef.current, ...items].filter(
(el): el is HTMLButtonElement => el !== null,
);
const index = order.indexOf(document.activeElement as HTMLButtonElement);
if (index === -1) return;
event.preventDefault();
const next =
event.key === "ArrowUp"
? Math.min(index + 1, order.length - 1)
: Math.max(index - 1, 0);
order[next]?.focus();
};
const right = position === "bottom-right";
return (
<>
<AnimatePresence>
{scrim && open && (
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: instant ? 0 : 0.15, ease: "easeOut" }}
onClick={() => setOpen(false)}
className="absolute inset-0 z-20 rounded-[inherit] bg-background/60"
/>
)}
</AnimatePresence>
<div
ref={rootRef}
data-slot="fab-stack"
onKeyDown={handleRootKeyDown}
className={cn(
"absolute bottom-4 z-30 flex flex-col-reverse gap-2.5",
right ? "right-4 items-end" : "left-4 items-start",
className,
)}
{...props}
>
<button
ref={mainRef}
type="button"
aria-label={label}
aria-expanded={open}
aria-controls={open ? stackId : undefined}
onClick={() => setOpen(!open)}
className={cn(
"inline-flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-overlay outline-none",
"transition-[scale,background-color,box-shadow] duration-150 ease-out hover:bg-primary/90",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.97]",
"[&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
)}
>
<span
aria-hidden
className={cn(
"flex rotate-0 transition-[rotate] duration-200 ease-(--ease-out) motion-reduce:transition-none",
open && "rotate-45",
)}
>
{icon ?? <Plus />}
</span>
</button>
<div
id={stackId}
role="group"
aria-label={label}
className={cn(
"flex flex-col-reverse gap-2",
right ? "items-end" : "items-start",
)}
>
<AnimatePresence>
{open &&
actions.map((action, index) => (
<motion.button
key={action.label}
type="button"
data-fab-stack-item=""
initial={
instant
? { opacity: 0 }
: {
opacity: 0,
y: 8,
scale: 0.9,
filter: "blur(4px)",
}
}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={
instant
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
y: 6,
filter: "blur(4px)",
transition: { duration: 0.12, ease: "easeIn" },
}
}
transition={{
type: "spring",
duration: 0.35,
bounce: 0,
delay: instant ? 0 : index * 0.04,
}}
onClick={() => {
action.onSelect?.();
if (closeOnSelect) {
setOpen(false);
mainRef.current?.focus();
}
}}
className={cn(
"group/fab-item flex items-center gap-2.5 rounded-full outline-none",
right ? "flex-row" : "flex-row-reverse",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span className="rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-border">
{action.label}
</span>
<span
className={cn(
"flex size-10 items-center justify-center rounded-full bg-card text-foreground shadow-border",
"transition-[scale,background-color,box-shadow] duration-150 ease-out",
"group-hover/fab-item:bg-accent group-hover/fab-item:shadow-border-hover",
!isStatic && "group-active/fab-item:scale-[0.97]",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
)}
>
{action.icon}
</span>
</motion.button>
))}
</AnimatePresence>
</div>
</div>
</>
);
}