Bloom Menu
A button that morphs open into a menu and blooms iris-out from the center, the grid revealing in every direction with radially staggered items.
Preview
"use client";
import { BloomMenu } from "@/components/motion/bloom-menu";
export function BloomMenuPreview() {
return (
<div className="flex min-h-[420px] w-full items-start justify-center pt-24">
<BloomMenu />
</div>
);
}
"use client";
// beui.dev/components/blocks/bloom-menu
import {
Bell,
FileText,
FolderClosed,
LayoutGrid,
Link,
Plus,
Table,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ComponentType, useEffect, useId, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type MenuItem = { label: string; icon: ComponentType<{ className?: string }> };
const ITEMS: MenuItem[] = [
{ label: "Doc", icon: FileText },
{ label: "Board", icon: LayoutGrid },
{ label: "Table", icon: Table },
{ label: "Folder", icon: FolderClosed },
{ label: "Reminder", icon: Bell },
{ label: "Link", icon: Link },
];
// Folder-open feel: a touch of overshoot as the panel expands, kept subtle.
const SPRING_FOLDER = {
type: "spring",
stiffness: 300,
damping: 32,
mass: 0.9,
} as const;
export interface BloomMenuProps {
items?: MenuItem[];
onSelect?: (label: string) => void;
className?: string;
}
export function BloomMenu({
items = ITEMS,
onSelect,
className,
}: BloomMenuProps) {
const [open, setOpen] = useState(false);
const reduce = useReducedMotion();
const layoutId = useId();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const morph = reduce ? { duration: 0.15 } : SPRING_FOLDER;
return (
<div ref={ref} className={cn("relative inline-flex", className)}>
{/* spacer fixes the anchor to the trigger size */}
<div className="h-11 w-36" aria-hidden />
{/* Centering box sized to the OPEN panel and centered on the trigger.
place-items-center only centers an item that fits its cell, so the cell
must be as wide as the panel — otherwise the overflow left-anchors and
the panel expands rightward. The box is a fixed size per viewport (vw
doesn't change mid-animation), so its -translate centering never drifts
the way a content-sized wrapper would. Both states share its center, so
the morph grows from the middle outward in every direction. */}
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 grid h-[300px] w-[min(86vw,420px)] -translate-x-1/2 -translate-y-1/2 place-items-center [&>*]:pointer-events-auto">
{/* popLayout pulls the exiting trigger out of grid flow at once, so the
grid never briefly holds two rows and shoves the panel off-center */}
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
className="w-[min(86vw,420px)] overflow-hidden border border-border bg-card"
>
<motion.div
// `layout` lets framer undo the box's morph scaling so this
// content stays crisp instead of stretching with the resize.
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reduce ? 0 : 0.12, duration: 0.2 }}
>
{/* header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium text-muted-foreground">
Create
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close menu"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* grid */}
<motion.div
// Iris reveal: start as a small box at the grid center and open
// outward to all four corners, so the menu grows from the middle
// in every direction instead of wiping top-down.
initial={
reduce ? false : { clipPath: "inset(45% 34% 45% 34%)" }
}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
transition={{
delay: reduce ? 0 : 0.08,
duration: 0.45,
ease: EASE_OUT,
}}
className="grid grid-cols-3"
>
{items.map((item, i) => {
// Radial stagger: delay each item by its distance from the
// grid center so the four corners animate together and the
// open reads as center-out, not corner-by-corner.
const cols = 3;
const rows = Math.ceil(items.length / cols);
const col = i % cols;
const row = Math.floor(i / cols);
const dist = Math.hypot(
col - (cols - 1) / 2,
row - (rows - 1) / 2,
);
return (
<button
key={item.label}
type="button"
onClick={() => {
onSelect?.(item.label);
setOpen(false);
}}
// Static cell with hairline borders (no animated fill) so
// the grid lines never flicker as items stagger in. Only the
// inner content animates.
className={cn(
"flex items-center justify-center px-3 py-6 text-muted-foreground transition-colors hover:text-foreground",
i % 3 !== 2 && "border-r border-border",
i < 3 && "border-b border-border",
)}
>
<motion.span
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.85, filter: "blur(6px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={{
delay: reduce ? 0 : 0.1 + dist * 0.07,
type: "spring",
stiffness: 440,
damping: 34,
}}
className="flex flex-col items-center gap-2"
>
<item.icon className="h-5 w-5" />
<span className="text-sm font-medium">{item.label}</span>
</motion.span>
</button>
);
})}
</motion.div>
</motion.div>
</motion.div>
) : (
<motion.button
key="trigger"
type="button"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
onClick={() => setOpen(true)}
aria-haspopup="menu"
aria-expanded={open}
whileTap={reduce ? undefined : { scale: 0.97 }}
className="inline-flex h-11 w-36 items-center justify-center border border-border bg-card text-sm font-medium text-foreground"
>
{/* own `layout` counter-scales the label so it stays crisp while the
button box morphs, instead of stretching with it */}
<motion.span
layout
className="inline-flex items-center gap-2 whitespace-nowrap"
>
Create
<Plus className="h-4 w-4" />
</motion.span>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/blocks/bloom-menu
import {
Bell,
FileText,
FolderClosed,
LayoutGrid,
Link,
Plus,
Table,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ComponentType, useEffect, useId, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type MenuItem = { label: string; icon: ComponentType<{ className?: string }> };
const ITEMS: MenuItem[] = [
{ label: "Doc", icon: FileText },
{ label: "Board", icon: LayoutGrid },
{ label: "Table", icon: Table },
{ label: "Folder", icon: FolderClosed },
{ label: "Reminder", icon: Bell },
{ label: "Link", icon: Link },
];
// Folder-open feel: a touch of overshoot as the panel expands, kept subtle.
const SPRING_FOLDER = {
type: "spring",
stiffness: 300,
damping: 32,
mass: 0.9,
} as const;
export interface BloomMenuProps {
items?: MenuItem[];
onSelect?: (label: string) => void;
className?: string;
}
export function BloomMenu({
items = ITEMS,
onSelect,
className,
}: BloomMenuProps) {
const [open, setOpen] = useState(false);
const reduce = useReducedMotion();
const layoutId = useId();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const morph = reduce ? { duration: 0.15 } : SPRING_FOLDER;
return (
<div ref={ref} className={cn("relative inline-flex", className)}>
{/* spacer fixes the anchor to the trigger size */}
<div className="h-11 w-36" aria-hidden />
{/* Centering box sized to the OPEN panel and centered on the trigger.
place-items-center only centers an item that fits its cell, so the cell
must be as wide as the panel — otherwise the overflow left-anchors and
the panel expands rightward. The box is a fixed size per viewport (vw
doesn't change mid-animation), so its -translate centering never drifts
the way a content-sized wrapper would. Both states share its center, so
the morph grows from the middle outward in every direction. */}
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 grid h-[300px] w-[min(86vw,420px)] -translate-x-1/2 -translate-y-1/2 place-items-center [&>*]:pointer-events-auto">
{/* popLayout pulls the exiting trigger out of grid flow at once, so the
grid never briefly holds two rows and shoves the panel off-center */}
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
className="w-[min(86vw,420px)] overflow-hidden border border-border bg-card"
>
<motion.div
// `layout` lets framer undo the box's morph scaling so this
// content stays crisp instead of stretching with the resize.
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reduce ? 0 : 0.12, duration: 0.2 }}
>
{/* header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium text-muted-foreground">
Create
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close menu"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* grid */}
<motion.div
// Iris reveal: start as a small box at the grid center and open
// outward to all four corners, so the menu grows from the middle
// in every direction instead of wiping top-down.
initial={
reduce ? false : { clipPath: "inset(45% 34% 45% 34%)" }
}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
transition={{
delay: reduce ? 0 : 0.08,
duration: 0.45,
ease: EASE_OUT,
}}
className="grid grid-cols-3"
>
{items.map((item, i) => {
// Radial stagger: delay each item by its distance from the
// grid center so the four corners animate together and the
// open reads as center-out, not corner-by-corner.
const cols = 3;
const rows = Math.ceil(items.length / cols);
const col = i % cols;
const row = Math.floor(i / cols);
const dist = Math.hypot(
col - (cols - 1) / 2,
row - (rows - 1) / 2,
);
return (
<button
key={item.label}
type="button"
onClick={() => {
onSelect?.(item.label);
setOpen(false);
}}
// Static cell with hairline borders (no animated fill) so
// the grid lines never flicker as items stagger in. Only the
// inner content animates.
className={cn(
"flex items-center justify-center px-3 py-6 text-muted-foreground transition-colors hover:text-foreground",
i % 3 !== 2 && "border-r border-border",
i < 3 && "border-b border-border",
)}
>
<motion.span
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.85, filter: "blur(6px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={{
delay: reduce ? 0 : 0.1 + dist * 0.07,
type: "spring",
stiffness: 440,
damping: 34,
}}
className="flex flex-col items-center gap-2"
>
<item.icon className="h-5 w-5" />
<span className="text-sm font-medium">{item.label}</span>
</motion.span>
</button>
);
})}
</motion.div>
</motion.div>
</motion.div>
) : (
<motion.button
key="trigger"
type="button"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
onClick={() => setOpen(true)}
aria-haspopup="menu"
aria-expanded={open}
whileTap={reduce ? undefined : { scale: 0.97 }}
className="inline-flex h-11 w-36 items-center justify-center border border-border bg-card text-sm font-medium text-foreground"
>
{/* own `layout` counter-scales the label so it stays crisp while the
button box morphs, instead of stretching with it */}
<motion.span
layout
className="inline-flex items-center gap-2 whitespace-nowrap"
>
Create
<Plus className="h-4 w-4" />
</motion.span>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}
API Reference
items?MenuItem[][
{ label: "Doc", icon: FileText },
{ label: "Board", icon: LayoutGrid },
{ label: "Table", icon: Table },
{ label: "Folder", icon: FolderClosed },
{ label: "Reminder", icon: Bell },
{ label: "Link", icon: Link },
]onSelect?((label: string) => void)—className?string—Related components
Expandable Tabs
Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.
Expandable Action Bar
Compact icon actions that expand into labeled controls on hover or focus with shared layout motion.
Overflow Actions
Connected pill rail for primary actions that springs open to reveal extra controls.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated