liten

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.

Terminal
bun add motion

Copy the component into components/ui/bloom.tsx.

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

Example.tsx
import { Bloom } from '@/components/ui/bloom';

export default function Example() {
  return <Bloom />;
}

Examples

Two columns, a custom item set, and a cyan accent.

Props

PropTypeDefaultDescription
labelstring"Create new"Text shown on the closed trigger.
titlestring-Title shown in the open panel header.
itemsBloomItem[]6 defaultsGrid of choices, each with a label and icon.
columnsnumber3Number of columns in the grid.
accentstring"#f0883e"Colors the focus outline only.
classNamestring-Forwarded to the root.
On this page0%