Overlays & Feedbackv0.1.0
Button-to-Menu Morph
The trigger button morphs into the menu surface.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/morph-menu.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --destructive-text, which shadcn does not define by default. Add it to your global CSS (--destructive-text falls back to your --destructive unless you set it):
app/globals.css@theme inline { --color-destructive-text: var(--destructive-text, var(--destructive)); }Copy the source
components/ui/morph-menu/morph-menu.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface MorphMenuItem { /** Visible label. */ label: string; /** Icon before the label. */ icon?: ReactNode; /** Shortcut hint at the end, e.g. "⌘N". */ shortcut?: string; /** Style the item as destructive. */ destructive?: boolean; /** Prevents choosing the item. */ disabled?: boolean; /** Called when the item is chosen; the menu then closes. */ onSelect?: () => void; } export interface MorphMenuProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Trigger content. Use an icon-only label together with aria-label. */ label: ReactNode; /** Icon before the trigger label. */ icon?: ReactNode; /** Menu items, in order. */ items: MorphMenuItem[]; /** Which edge the menu grows from. @default "start" */ align?: "start" | "end"; /** Surface color, shared by the button and the menu so the morph is seamless. @default "light" */ tone?: "light" | "dark"; /** Accessible name for the trigger and menu when the label is an icon. */ "aria-label"?: string; } const tones = { light: { surface: "border border-border bg-popover text-popover-foreground shadow-lg", button: "border border-border bg-background text-foreground shadow-xs hover:bg-accent", item: "bg-accent", muted: "text-muted-foreground", }, dark: { surface: "bg-foreground text-background shadow-xl", button: "bg-foreground text-background shadow-md hover:bg-foreground/90", item: "bg-background/12", muted: "text-background/55", }, } as const; // Shared by the button↔menu morph so both directions feel the same. const MORPH = { type: "spring", stiffness: 420, damping: 34, mass: 0.9 } as const; export function MorphMenu({ label, icon, items, align = "start", tone = "light", className, "aria-label": ariaLabel, ...props }: MorphMenuProps) { const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const rootRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const returnFocus = useRef(false); const reduceMotion = useReducedMotion(); const groupId = useId(); const menuId = useId(); const t = tones[tone]; const morph = reduceMotion ? { duration: 0 } : MORPH; const enabled = items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0); const firstEnabled = enabled[0] ?? 0; // The trigger unmounts while the menu is open, so focus is handed over by hand. useEffect(() => { if (open) { itemRefs.current[firstEnabled]?.focus({ preventScroll: true }); } else if (returnFocus.current) { returnFocus.current = false; triggerRef.current?.focus({ preventScroll: true }); } }, [open, firstEnabled]); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); }, [open]); function close(focusTrigger: boolean) { returnFocus.current = focusTrigger; setOpen(false); } function openMenu() { setActive(firstEnabled); setOpen(true); } function focusItem(index: number) { setActive(index); itemRefs.current[index]?.focus({ preventScroll: true }); } function onMenuKey(event: KeyboardEvent<HTMLDivElement>) { const position = enabled.indexOf(active); const moves: Record<string, number | undefined> = { ArrowDown: enabled[(position + 1) % enabled.length], ArrowUp: enabled[(position - 1 + enabled.length) % enabled.length], Home: enabled[0], End: enabled[enabled.length - 1], }; if (event.key in moves) { event.preventDefault(); const next = moves[event.key]; if (next !== undefined) focusItem(next); } else if (event.key === "Escape") { event.preventDefault(); close(true); } else if (event.key === "Tab") { close(false); } } const triggerContent = ( <> {icon} {label} </> ); return ( <div ref={rootRef} className={cn("relative inline-flex", className)} {...props}> {/* Reserves the trigger's space while the menu is open. */} <span aria-hidden="true" className="invisible inline-flex h-10 items-center gap-2 px-4 text-sm font-medium [&_svg]:size-4"> {triggerContent} </span> <LayoutGroup id={groupId}> {!open ? ( <motion.button ref={triggerRef} layoutId="surface" transition={morph} type="button" aria-haspopup="menu" aria-expanded={false} aria-label={ariaLabel} onClick={openMenu} onKeyDown={(event) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); openMenu(); } }} style={{ borderRadius: 20 }} className={cn( "absolute inset-0 z-10 inline-flex cursor-pointer items-center justify-center gap-2 overflow-hidden px-4 text-sm font-medium whitespace-nowrap outline-none", "transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:size-4", t.button, )} > <motion.span layout="position" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: duration.quick, delay: reduceMotion ? 0 : 0.06 }} className="inline-flex items-center gap-2" > {triggerContent} </motion.span> </motion.button> ) : ( <motion.div layoutId="surface" transition={morph} id={menuId} role="menu" aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)} aria-orientation="vertical" onKeyDown={onMenuKey} style={{ borderRadius: 14 }} className={cn("absolute top-0 z-50 w-56 overflow-hidden p-1", align === "end" ? "right-0" : "left-0", t.surface)} > {items.map((item, index) => ( <motion.button key={item.label} ref={(node) => { itemRefs.current[index] = node; }} type="button" role="menuitem" tabIndex={index === active ? 0 : -1} aria-disabled={item.disabled || undefined} onPointerMove={() => !item.disabled && index !== active && focusItem(index)} onClick={() => { if (item.disabled) return; item.onSelect?.(); close(true); }} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ duration: duration.quick, ease: ease.out, delay: reduceMotion ? 0 : 0.08 + index * 0.025 }} className={cn( "relative flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none select-none [&_svg]:size-4 [&_svg]:shrink-0", item.destructive && "text-destructive-text", item.disabled && "cursor-not-allowed opacity-45", )} > {index === active && !item.disabled ? ( <motion.span layoutId="item-highlight" aria-hidden="true" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className={cn("absolute inset-0 rounded-md", item.destructive ? "bg-destructive/12" : t.item)} /> ) : null} {item.icon ? <span className={cn("relative flex", !item.destructive && t.muted)}>{item.icon}</span> : null} <span className="relative flex-1 truncate">{item.label}</span> {item.shortcut ? <kbd className={cn("relative font-sans text-xs tracking-widest", t.muted)}>{item.shortcut}</kbd> : null} </motion.button> ))} </motion.div> )} </LayoutGroup> </div> ); }
Usage
import { MorphMenu } from "@/components/ui/morph-menu/morph-menu";
export function FileActions() {
return (
<MorphMenu
label="Actions"
items={[
{ label: "New file", onSelect: createFile },
{ label: "Delete", destructive: true, onSelect: deleteFile },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label* | ReactNode | – | Trigger content. Use an icon-only label together with aria-label. |
icon | ReactNode | – | Icon before the trigger label. |
items* | MorphMenuItem[] | – | Menu items, in order. |
align | "start" | "end" | "start" | Which edge the menu grows from. |
tone | "light" | "dark" | "light" | Surface color, shared by the button and the menu so the morph is seamless. |
"aria-label" | string | – | Accessible name for the trigger and menu when the label is an icon. |
Also accepts every native <div> attribute, including ref and className.
- The button and the menu share a Motion layoutId, so the button grows into the menu and shrinks back from it.
- Both surfaces use the same tone, so the color never jumps mid-morph.
- An invisible copy of the trigger reserves its space while the menu is open, so surrounding layout never shifts.
Accessibility
- The trigger is a button with aria-haspopup="menu"; Enter, Space, ArrowDown or ArrowUp opens it and focus moves to the first enabled item.
- The open surface is role="menu" with menuitems and a roving tabindex. Arrow keys, Home and End move, Escape closes and returns focus to the button, and Tab closes.
- Clicking outside closes the menu; choosing an item runs onSelect, closes the menu and returns focus.
- Disabled items are announced with aria-disabled and skipped by the keyboard.
- With reduced motion enabled, the button and menu swap without morphing and items appear without sliding.