Bloom
A trigger that morphs into a panel of choices, expanding to a measured size instead of a scale transform.
The closed trigger and the open panel are the same surface: it measures its content and animates its width and height to fit, so the shape change reads as one continuous morph rather than a swap. The grid of choices staggers in after the surface settles, and Escape or a backdrop click closes it. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/bloom.tsx.
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion, type Transition,} from 'motion/react';import { Folder, FileText, Copy, Trophy, Flag, Calendar, Plus, X, Sparkles,} from 'lucide-react';import { cn } from '@/lib/cn';export type BloomItem = { label: string; icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; onSelect?: () => void;};export type BloomProps = { label?: string; title?: string; items?: BloomItem[]; columns?: number; accent?: string; className?: string;};const DEFAULT_ITEMS: BloomItem[] = [ { label: 'Project', icon: Folder }, { label: 'Notebook', icon: FileText }, { label: 'Notes', icon: Copy }, { label: 'Goal', icon: Trophy }, { label: 'Milestone', icon: Flag }, { label: 'Event', icon: Calendar },];const MORPH: Transition = { type: 'spring', duration: 0.5, bounce: 0.16 };const EASE_OUT = [0.23, 1, 0.32, 1] as const;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--bloom-accent)]';const useIsoLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;export function Bloom({ label = 'Create new', title, items = DEFAULT_ITEMS, columns = 3, accent = '#f0883e', className,}: BloomProps) { const [open, setOpen] = React.useState(false); const reduce = useReducedMotion(); const headingId = React.useId(); const morph: Transition = reduce ? { duration: 0.15, ease: EASE_OUT } : MORPH; const contentRef = React.useRef<HTMLDivElement>(null); const [size, setSize] = React.useState<{ width: number; height: number } | null>( null, ); useIsoLayoutEffect(() => { const el = contentRef.current; if (!el) return; const measure = () => { const width = el.offsetWidth; const height = el.offsetHeight; setSize((p) => p && p.width === width && p.height === height ? p : { width, height }, ); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [open]); React.useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open]); return ( <div style={{ '--bloom-accent': accent } as React.CSSProperties} className={cn('relative inline-grid place-items-center', className)} > <AnimatePresence> {open && ( <motion.div key="backdrop" className="fixed inset-0 z-40 bg-black/40 backdrop-blur-[2px] dark:bg-black/55" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }} transition={{ duration: 0.2, ease: EASE_OUT }} onClick={() => setOpen(false)} /> )} </AnimatePresence> <motion.div role={open ? 'dialog' : undefined} aria-modal={open || undefined} aria-labelledby={open ? headingId : undefined} data-state={open ? 'open' : 'closed'} animate={size ? { width: size.width, height: size.height } : undefined} transition={morph} className={cn( 'bloom-edge group relative z-50 overflow-hidden rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]', !open && 'cursor-pointer', )} > <div ref={contentRef} className="w-max"> <AnimatePresence mode="popLayout" initial={false}> {open ? ( <motion.div key="panel" className="w-[268px] max-w-[calc(100vw-2rem)]" initial={{ opacity: 0, filter: 'blur(4px)' }} animate={{ opacity: 1, filter: 'blur(0px)' }} exit={{ opacity: 0, filter: 'blur(4px)', transition: { duration: 0.12, ease: EASE_OUT } }} transition={{ duration: 0.2, ease: EASE_OUT }} > <div className="flex items-center gap-2 p-2"> <span className="bloom-edge grid size-7 shrink-0 place-items-center rounded-lg bg-black/[0.03] text-neutral-500 dark:bg-white/[0.04] dark:text-[#bdbdbd]"> <Sparkles className="size-[15px]" strokeWidth={1.75} /> </span> <h2 id={headingId} className="text-[13px] font-medium tracking-[-0.01em] text-neutral-900 dark:text-white" > {title ?? label} </h2> <button type="button" onClick={() => setOpen(false)} aria-label="Close" className={cn( 'ml-auto flex size-7 items-center justify-center rounded-lg', 'text-neutral-400 transition-[color,transform] duration-200 ease-out hover:text-neutral-900', 'active:scale-90 dark:text-[#929292] dark:hover:text-white', FOCUS, )} > <X className="size-4" strokeWidth={2} /> </button> </div> <div className={cn( 'bloom-edge m-2 mt-0 rounded-[11px]', 'bg-black/[0.02] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', )} > <motion.div className="grid gap-1 p-1.5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }} initial="hidden" animate="show" variants={{ show: { transition: { staggerChildren: 0.035, delayChildren: 0.06 }, }, }} > {items.map((item) => ( <BloomChoice key={item.label} item={item} reduce={!!reduce} onClose={() => setOpen(false)} /> ))} </motion.div> </div> </motion.div> ) : ( <motion.button key="trigger" type="button" onClick={() => setOpen(true)} initial={{ opacity: 0, filter: 'blur(4px)' }} animate={{ opacity: 1, filter: 'blur(0px)' }} exit={{ opacity: 0, filter: 'blur(4px)', transition: { duration: 0.12, ease: EASE_OUT } }} transition={{ duration: 0.2, ease: EASE_OUT }} whileTap={reduce ? undefined : { scale: 0.97 }} className={cn( 'flex items-center gap-1 p-1.5', FOCUS, 'focus-visible:[outline-offset:-2px]', )} > <span className="px-2.5 py-1 text-[13px] font-medium tracking-[-0.01em] text-neutral-500 transition-colors duration-300 group-hover:text-neutral-900 dark:text-[#929292] dark:group-hover:text-white"> {label} </span> <span className="flex size-7 items-center justify-center text-neutral-900 dark:text-white"> <Plus className="size-4" strokeWidth={2} /> </span> </motion.button> )} </AnimatePresence> </div> </motion.div> </div> );}function BloomChoice({ item, reduce, onClose,}: { item: BloomItem; reduce: boolean; onClose: () => void;}) { const Icon = item.icon; return ( <motion.button type="button" variants={{ hidden: { opacity: 0, y: reduce ? 0 : 5 }, show: { opacity: 1, y: 0, transition: { duration: 0.22, ease: EASE_OUT } }, }} whileTap={reduce ? undefined : { scale: 0.96 }} onClick={() => { item.onSelect?.(); onClose(); }} className={cn( 'group/item flex flex-col items-center gap-1.5 rounded-[10px] px-1 py-3', 'transition-[background-color,box-shadow] duration-200 ease-out', 'hover:bg-white hover:shadow-[0_0_0_1px_rgba(0,0,0,0.06),inset_0_1px_0_0_rgba(255,255,255,0.9),0_2px_5px_-1px_rgba(0,0,0,0.12)]', 'dark:hover:bg-[#242424] dark:hover:shadow-[0_0_0_1px_rgba(255,255,255,0.08),inset_0_1px_0_0_rgba(255,255,255,0.08),0_3px_8px_-2px_rgba(0,0,0,0.6)]', FOCUS, )} > <Icon className="size-[18px] text-neutral-400 transition-colors duration-200 group-hover/item:text-neutral-900 dark:text-[#929292] dark:group-hover/item:text-white" strokeWidth={1.75} /> <span className="text-[11px] font-medium text-neutral-500 transition-colors duration-200 group-hover/item:text-neutral-900 dark:text-[#8b8b8b] dark:group-hover/item:text-white"> {item.label} </span> </motion.button> );}export { DEFAULT_ITEMS as bloomDefaultItems };Usage
import { Bloom } from '@/components/ui/bloom';
export default function Example() {
return <Bloom />;
}Examples
Two columns, a custom item set, and a cyan accent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Create new" | Text shown on the closed trigger. |
title | string | - | Title shown in the open panel header. |
items | BloomItem[] | 6 defaults | Grid of choices, each with a label and icon. |
columns | number | 3 | Number of columns in the grid. |
accent | string | "#f0883e" | Colors the focus outline only. |
className | string | - | Forwarded to the root. |