A floating plus button in the corner of an app screen that grows into its own menu. The 64 pixel square springs out to a panel of four actions, the rows rise in one after another, and the plus turns into a close mark in the same corner. Choosing an action or pressing the mark folds it back. It is shown inside a notes app frame and opens on load so the menu is visible straight away. On phones the panel covers the notes behind it, as a real one would.
// Adapted from Cult UI (https://cult-ui.com/docs/components/family-button)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Camera, ListChecks, Mic, PenLine, Plus } from "lucide-react";
const actions = [
{ icon: PenLine, label: "New note", hint: "N" },
{ icon: ListChecks, label: "Checklist", hint: "L" },
{ icon: Mic, label: "Voice memo", hint: "V" },
{ icon: Camera, label: "Scan a page", hint: "S" },
];
const notes = [
{ title: "Kiln schedule", body: "Bisque fire Tuesday, glaze fire Friday. Ask Tomas about cone 6.", tone: "bg-lime-300/10 text-lime-200" },
{ title: "Market stall", body: "Bring the float, the card reader and forty paper bags.", tone: "bg-sky-300/10 text-sky-200" },
{ title: "Glaze test 14", body: "Celadon over iron slip ran too far on the rim. Thinner coat next time.", tone: "bg-amber-300/10 text-amber-200" },
];
/** Closed and open size of the button, in pixels. */
const CLOSED = { width: 64, height: 64, borderRadius: 22 };
const OPEN = { width: 232, height: 276, borderRadius: 26 };
export default function ExpandingActionButtonThatMorphsIntoAQuickMenu() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(true);
return (
<section className="bg-neutral-950 px-4 py-16 sm:px-6 sm:py-20">
<div className="mx-auto max-w-3xl">
<div className="text-center">
<h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Everything you can add, one tap away.</h2>
<p className="mx-auto mt-4 max-w-xl text-base leading-7 text-neutral-400">The plus button does not open a menu. It becomes the menu, then folds back into a button when you are done.</p>
</div>
{/* A notes app screen. The button is anchored to its bottom right corner. */}
<div className="relative mt-10 h-[460px] overflow-hidden rounded-3xl border border-white/10 bg-neutral-900">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<p className="text-sm font-semibold text-white">Studio notebook</p>
<p className="text-xs text-neutral-400">3 notes ยท synced</p>
</div>
<ul className="grid gap-3 p-5 sm:grid-cols-3">
{notes.map((n) => (
<li key={n.title} className="rounded-2xl border border-white/10 bg-neutral-950/60 p-4">
<span className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-medium ${n.tone}`}>Note</span>
<p className="mt-3 text-sm font-semibold text-white">{n.title}</p>
<p className="mt-1 text-sm leading-6 text-neutral-400">{n.body}</p>
</li>
))}
</ul>
{/* Stacked hairline borders give the button its machined edge. */}
<div className="absolute bottom-4 right-4 rounded-[30px] border border-black/60 bg-gradient-to-b from-neutral-700 to-neutral-900 p-[3px] shadow-2xl shadow-black/60">
<motion.div
initial={reduce ? false : CLOSED}
animate={open ? OPEN : CLOSED}
transition={reduce ? { duration: 0 } : { type: "spring", damping: 25, stiffness: 400 }}
className="relative overflow-hidden border border-white/10 bg-gradient-to-b from-neutral-800 to-neutral-900"
>
<AnimatePresence>
{open && (
<motion.div
id="expanding-action-button-menu"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1, transition: { delay: reduce ? 0 : 0.18, duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.08 } }}
className="absolute bottom-0 right-0 w-[230px] p-2"
>
<p className="px-2 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-widest text-neutral-500">Add to notebook</p>
<ul>
{actions.map((a, i) => (
<motion.li
key={a.label}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: reduce ? 0 : 0.2 + i * 0.04, type: "spring", stiffness: 380, damping: 28 }}
>
<button type="button" onClick={() => setOpen(false)} className="flex h-11 w-full items-center gap-3 rounded-xl px-2 text-left text-sm font-medium text-neutral-100 transition-colors hover:bg-white/10 focus-visible:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-300">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-white/10 text-lime-300">
<a.icon aria-hidden="true" className="h-4 w-4" />
</span>
<span className="flex-1">{a.label}</span>
{i < 2 && <kbd className="rounded border border-white/10 px-1.5 text-[11px] text-neutral-500">{a.hint}</kbd>}
</button>
</motion.li>
))}
</ul>
{/* Keeps the last row clear of the round toggle. */}
<div className="h-14" />
</motion.div>
)}
</AnimatePresence>
<motion.button
type="button"
aria-expanded={open}
aria-controls="expanding-action-button-menu"
aria-label={open ? "Close the add menu" : "Open the add menu"}
onClick={() => setOpen((o) => !o)}
initial={false}
animate={{ rotate: open ? 135 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 22 }}
className={`absolute bottom-[7px] right-[7px] flex h-12 w-12 items-center justify-center rounded-full transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900 ${
open ? "bg-neutral-950 text-neutral-300 ring-1 ring-white/15" : "bg-lime-300 text-neutral-950"
}`}
>
<Plus aria-hidden="true" className="h-6 w-6" />
</motion.button>
</motion.div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/expanding-action-button-that-morphs-into-a-quick-menu.json
There is one `motion.div` with `overflow-hidden`. Its `width`, `height` and `borderRadius` animate between the `CLOSED` and `OPEN` values with a spring. The menu content is positioned from the bottom right corner at its final size, so it is revealed as the shell grows instead of being squashed.
The button sits in the bottom right of the screen, so it has to grow up and to the left. Anchoring the content to the same corner keeps the toggle still while everything else expands away from it.
Change `useState(true)` to `useState(false)`. It opens on load here only so a preview shows the menu.
Yes. Copy the wrapper that has `absolute bottom-4 right-4` and everything inside it. In a real app give that wrapper `fixed` so it stays in the corner of the window.
The shell has a dark outer border, a three pixel gradient rim and a faint white inner border. Three thin lines read as a bevel without any heavy shadow.
Adapted from Family Button in Cult UI, used under its open-source licence. Licences and credits
Related: Floating Toolbar Expanding from a Pill with Tool Options, Magnifying Dock with Tooltips and Idle Wave, Habit Tracker Phone Screen with Tab Bar.
More docks and floating menus or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.