Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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));
    }
  4. 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

PropTypeDefaultDescription
label*ReactNode–Trigger content. Use an icon-only label together with aria-label.
iconReactNode–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.