Floating Action Expand
The round action button opens into a labelled list of actions that climb out of it, nearest first.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Floating Action Expand
*
* The round action button opens into the list of things it can actually
* do: the actions climb out of it one after another, nearest first, and
* fold back the same way. The plus turns into the close.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `label`.
* Press the round button, or Escape to close.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FloatingActionExpandProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Accessible name for the round button. */
label?: string;
/** Fires with the chosen action's label. */
onAction?: (action: string) => void;
/** Fires whenever the list opens or closes. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
/** Seconds between one action leaving the button and the next. */
stagger: number;
/** How far each action rises from the button, in px. */
lift: number;
/** Size the icon disc grows from, as a fraction. */
discFrom: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds for the whole set to fold away. */
exit: number;
};
// Quality rule: the labels only fade and translate — never scale — so no
// glyph is ever resized. The scale belongs to the icon discs, which carry
// no text, and every spring sits at or above a 0.8 damping ratio so the
// column lands once instead of jostling. Variants change the interval and
// the travel, never the number of settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly simultaneous, short travel. For a dense tool where the menu is
// opened dozens of times a day.
subtle: {
stagger: 0.025,
lift: 8,
discFrom: 0.9,
spring: { type: "spring", stiffness: 560, damping: 42 },
exit: 0.12,
},
// A readable climb: each action clears the one below it. All-purpose.
default: {
stagger: 0.045,
lift: 14,
discFrom: 0.72,
spring: { type: "spring", stiffness: 460, damping: 37 },
exit: 0.14,
},
// Longer interval and more travel, so the set reads as unfolding out of
// the button — for a consumer app where this is the main action.
playful: {
stagger: 0.06,
lift: 20,
discFrom: 0.6,
spring: { type: "spring", stiffness: 400, damping: 33 },
exit: 0.16,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Action = { label: string; icon: ReactNode };
const ACTIONS: Action[] = [
{
label: "New document",
icon: (
<>
<path d="M4.4 2.6h4.4l3.2 3.2v7.6H4.4z" />
<path d="M8.8 2.6v3.2H12" />
</>
),
},
{
label: "Upload file",
icon: (
<>
<path d="M8 11.4V4.2" />
<path d="M5.2 6.8 8 4l2.8 2.8" />
<path d="M3.4 12.6h9.2" />
</>
),
},
{
label: "Invite teammate",
icon: (
<>
<circle cx="6.6" cy="6" r="2.3" />
<path d="M2.8 13c0-2.1 1.7-3.5 3.8-3.5s3.8 1.4 3.8 3.5" />
<path d="M11.8 5.2v3.2M13.4 6.8h-3.2" />
</>
),
},
{
label: "New folder",
icon: (
<>
<path d="M2.6 4.4h3.6l1.2 1.6h6v6.6H2.6z" />
</>
),
},
];
const PROJECTS = [
["Billing v2", "8 open tasks"],
["Search relevance", "3 open tasks"],
["Mobile shell", "12 open tasks"],
] as const;
export default function FloatingActionExpand({
variant = "default",
label = "Create",
onAction,
onOpenChange,
}: FloatingActionExpandProps) {
const [open, setOpen] = useState(false);
const [chosen, setChosen] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const setMenu = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
onOpenChange?.(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
// Reduced motion: the actions still arrive as a set and still leave as
// one, they just stop climbing and stop arriving one at a time.
const listVariants = {
hidden: {
transition: {
staggerChildren: reduceMotion ? 0 : cfg.stagger,
// Closing runs from the far end back down to the button, which is
// the entrance played in reverse rather than a second idea.
staggerDirection: -1 as const,
},
},
visible: {
transition: { staggerChildren: reduceMotion ? 0 : cfg.stagger },
},
};
const rowVariants = reduceMotion
? {
hidden: { opacity: 0, transition: { duration: 0.1 } },
visible: { opacity: 1, transition: { duration: 0.12 } },
}
: {
hidden: {
opacity: 0,
y: cfg.lift,
transition: { duration: cfg.exit, ease: "easeIn" as const },
},
visible: { opacity: 1, y: 0, transition: cfg.spring },
};
const discVariants = reduceMotion
? { hidden: {}, visible: {} }
: {
hidden: {
scale: cfg.discFrom,
transition: { duration: cfg.exit, ease: "easeIn" as const },
},
visible: { scale: 1, transition: cfg.spring },
};
return (
<div
style={{
position: "relative",
width: 320,
height: 336,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div style={{ padding: "16px 16px 0" }}>
<div style={{ fontSize: 15, fontWeight: 650 }}>Projects</div>
<div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
{chosen ? `Last action: ${chosen}` : "3 active · 1 archived"}
</div>
<div style={{ marginTop: 12 }}>
{PROJECTS.map(([name, meta]) => (
<div
key={name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
padding: "11px 12px",
marginBottom: 8,
borderRadius: 12,
background: tone(7),
border: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>{name}</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{meta}</span>
</div>
))}
</div>
</div>
{/* The scrim, the actions and the button are all positioned against
this panel rather than the viewport, so the pattern drops into a
card unchanged. For an app-level action button, swap `absolute`
for `fixed` on all three and keep the same offsets. */}
<AnimatePresence>
{open && (
<motion.div
key="scrim"
aria-hidden
onClick={() => setMenu(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: cfg.exit } }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
// A scrim darkens in both themes, so it stays literal.
background: "rgba(0,0,0,0.36)",
cursor: "pointer",
}}
/>
)}
</AnimatePresence>
<AnimatePresence>
{open && (
<motion.ul
key="actions"
variants={listVariants}
initial="hidden"
animate="visible"
exit="hidden"
style={{
position: "absolute",
right: 16,
bottom: 74,
zIndex: 2,
display: "flex",
flexDirection: "column-reverse",
alignItems: "flex-end",
gap: 10,
margin: 0,
padding: 0,
listStyle: "none",
}}
>
{ACTIONS.map((action) => (
<motion.li
key={action.label}
variants={rowVariants}
style={{ display: "flex", alignItems: "center", gap: 9 }}
>
<span
style={{
padding: "5px 9px",
borderRadius: 8,
fontSize: 12,
fontWeight: 600,
whiteSpace: "nowrap",
// Opaque, not toned: these sit above the scrim, and a
// translucent label would composite with it and read as
// more scrim. `Canvas`/`CanvasText` are the CSS system
// colors for page background and page text, so the pill
// lands light in a light app and dark in a dark one.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
}}
>
{action.label}
</span>
<motion.button
type="button"
variants={discVariants}
onClick={() => {
setChosen(action.label);
onAction?.(action.label);
setMenu(false);
}}
whileTap={reduceMotion ? undefined : { scale: 0.92 }}
aria-label={action.label}
style={{
display: "grid",
placeItems: "center",
width: 38,
height: 38,
borderRadius: 19,
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 6px 16px rgba(0,0,0,0.24)",
cursor: "pointer",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke={ACCENT}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{action.icon}
</svg>
</motion.button>
</motion.li>
))}
</motion.ul>
)}
</AnimatePresence>
<motion.button
type="button"
onClick={() => setMenu(!open)}
aria-label={open ? "Close actions" : label}
aria-expanded={open}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
style={{
position: "absolute",
right: 16,
bottom: 16,
zIndex: 3,
display: "grid",
placeItems: "center",
width: 52,
height: 52,
borderRadius: 26,
border: 0,
background: ACCENT,
color: "#ffffff",
boxShadow: "0 10px 26px rgba(0,0,0,0.3)",
cursor: "pointer",
}}
>
{/* One glyph, not two: rotating the plus a quarter-turn lands it
exactly on the close, so the button never blinks between two
different icons. */}
<motion.svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
aria-hidden
initial={false}
animate={{ rotate: open ? 45 : 0 }}
transition={
reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 37 }
}
>
<path d="M10 4.4v11.2M4.4 10h11.2" />
</motion.svg>
</motion.button>
</div>
);
}About this pattern
One button that stands for several actions, and the moment it admits it. The actions leave the button in order — the nearest one first — so the set reads as unfolding out of the control rather than appearing around it, and closing runs the same order backwards instead of inventing a second idea. The labels only fade and travel; the scale belongs to the icon discs, which carry no type, because a resized word is the fastest way to make a menu look cheap. The plus does not swap for a close glyph, it rotates a quarter-turn into one, so the button never blinks. A scrim under the set makes the rest of the screen plainly inert while it is open, and Escape closes it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
A primary round action that opens into a short set of labelled actions.
Related patterns
- Tab Bar Icon SelectThe chosen icon fills and lifts while its label brightens, and the icon you left releases.
- Mobile Menu FullscreenA hamburger becomes a close glyph as a full-screen menu wipes in with staggered links.
- Back to Top AppearA return control lifts into the corner once the reader is deep enough for it to matter.