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 upward
  • width: MIN → MAX — Expands horizontally
  • height: MIN → measuredHeight — Matches content height
  • overflow-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

  1. Create a FAB with isOpen state
  2. Use AnimatePresence for icon transitions
  3. Mount/unmount the panel with animation
  4. Animate y, width, and height
  5. Measure content height with useMeasure
  6. Stagger item appearance
  7. Not still clear? Get the source code

Optional Enhancements

  • Keyboard navigation
  • Focus trapping for accessibility