Bloom Menu
NewA button that morphs open into a menu and blooms iris-out from the center, the grid revealing in every direction with radially staggered items.
TSXcomponents/previews/blocks/bloom-menu.preview.tsx
"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>
);
}
TSXcomponents/motion/bloom-menu.tsx
"use client";
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.
$ bunx --bun shadcn add @beui/bloom-menu
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/bloom-menu.tsx
"use client";
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>
);
}
Related components
Multi-chain Swap
Cross-chain swap widget with chain + token selectors, morphing views, animated flip and quote.
Dynamic Island
iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.
Command Palette
⌘K palette with fuzzy filter, spring-animated active row and glass surface.