Expanding Compose Button
A floating compose button that morphs into an animated expandable menu with smooth size transitions and staggered action items inspired by Notion's compose button
Loading demo...
Key pieces and how they exist
1) Constants
These constants define layout and motion behavior for this animation:
const DIMENSIONS = { MIN: 50, MAX: 250 };
const ITEMS = [
{ title: "Page", icon: File02Icon },
{ title: "Chat", icon: Chat01Icon },
{ title: "AI meeting note", icon: AiAudioIcon },
{ title: "Database", icon: Database01Icon },
];
const EASING: Easing = [0.22, 1, 0.36, 1];- DIMENSIONS.MIN — Closed button size
- DIMENSIONS.MAX — Expanded panel width
- ITEMS — Menu data model
- EASING — Animation curve
2) State
const [isOpen, setIsOpen] = useState(false);
const handleClick = () => setIsOpen((prev) => !prev);isOpen controls:
- icon swap
- panel mount/unmount
- expand/collapse animation
- item fade-in
3) Icon Swap
AnimatePresence is used with mode="wait" so the previous icon exits before the next enters.
<AnimatePresence mode="wait">
{isOpen ? (
<m.span
key="cancel"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<HugeiconsIcon icon={Cancel01Icon} />
</m.span>
) : (
<m.span
key="compose"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<HugeiconsIcon icon={PencilEdit02Icon} />
</m.span>
)}
</AnimatePresence>Unique keys ensure proper enter/exit transitions.
4) Panel Expansion
The panel animates width, height, and vertical position:
{
isOpen && (
<m.div
initial={{ y: 50, width: DIMENSIONS.MIN, height: DIMENSIONS.MIN }}
animate={{
y: 0,
width: DIMENSIONS.MAX,
height: bounds.height ? bounds.height + 16 : "auto",
}}
exit={{
width: DIMENSIONS.MIN - 10,
height: DIMENSIONS.MIN - 10,
borderRadius: 25,
y: 50,
transition: {
/* per-property timings */
},
}}
transition={
{
/* per-property timings */
}
}
style={{ borderRadius: 25, willChange: "transform, opacity" }}
className="absolute ... bottom-[calc(100%+10px)] overflow-hidden"
>
...
</m.div>
);
}Animation behavior:
y: 50 → 0— Slides upwardwidth: MIN → MAX— Expands horizontallyheight: MIN → measuredHeight— Matches content heightoverflow-hidden— Prevents content overflow during resize
5) Dynamic Height (react-use-measure)
const [ref, bounds] = useMeasure();Content is measured:
<div ref={ref}>
{ITEMS.map(...)}
</div>Height animation uses the measured value:
height: bounds.height ? bounds.height + 16 : "auto";Measuring converts height: auto into a numeric value so it can be animated.
6) Item Rendering
Items are rendered from data and fade in with a stagger:
{
ITEMS.map((item, idx) => (
<m.div
key={item.title}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: idx * 0.05 }}
className="flex items-center gap-2 p-2 hover:bg-muted rounded-3xl"
>
<HugeiconsIcon icon={item.icon} className="size-5" />
<p className="whitespace-pre">{item.title}</p>
</m.div>
));
}- Data-driven rendering
- Index-based stagger
- Opacity-only animation
Implementation Blueprint
- Create a FAB with
isOpenstate - Use
AnimatePresencefor icon transitions - Mount/unmount the panel with animation
- Animate
y,width, andheight - Measure content height with
useMeasure - Stagger item appearance
- Not still clear? Get the source code
Optional Enhancements
- Keyboard navigation
- Focus trapping for accessibility