Create Menu

A create button does not say what you can make, and a dropdown answers as a list you have to read in sequence to find the one item you came for.

SelectionProductivityDashboardEditoradvanced
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/create-menu.json

The problem

A "New" button does not say what you can make. A dropdown answers that, but it answers it as a list — six labels read in sequence before you find the one you came for. A grid of labelled icons is scannable in a single pass, because you recognise a shape rather than reading a word. Having the trigger morph into the grid keeps the two connected, so it reads as the same control opening rather than a panel arriving from somewhere else.

Use cases

  • A create or compose control with four to nine item types
  • An "add block" menu in an editor
  • Any picker where the options have strong, distinct icons

Not for: long or open-ended lists. Past about nine options the grid stops being scannable and you want a searchable command palette. Also not for options without recognisable icons — a grid of near-identical glyphs is slower to read than a list.

Installation

bash
npm install motion lucide-react

Then copy CreateMenu.tsx. It depends only on @/lib/cn.

Usage

tsx
import CreateMenu from "@/patterns/create-menu/CreateMenu";
import { Folder, CheckSquare } from "lucide-react";

<CreateMenu
  options={[
    {
      id: "project",
      label: "Project",
      icon: <Folder />,
      onSelect: () => newProject(),
    },
    {
      id: "task",
      label: "Task",
      icon: <CheckSquare />,
      onSelect: () => newTask(),
    },
  ]}
/>;

API

PropTypeDefaultDescription
optionsreadonly CreateMenuOption[]—The items. Empty renders nothing.
triggerLabelstring"Create New"Trigger text, reused as the menu heading.
columnsnumber3Grid width. Drives vertical arrow navigation.
defaultOpenbooleanfalseUncontrolled initial state.
openbooleanuncontrolledControlled state. Pass with onOpenChange.
onOpenChange(open: boolean) => void—Fires on every toggle.
classNamestring—Merged onto the root.

CreateMenuOption: { id: string; label: string; icon: ReactNode; onSelect: () => void }.

Keyboard

KeyAction
Enter / SpaceOpen the menu, or choose the focused item
← →Previous or next item, wrapping through the whole grid
↑ ↓Move by one row, wrapping within the column
Home / EndFirst or last item
EscapeClose and return focus to the trigger

Accessibility

The options did nothing. They were cursor-pointer divs with no click handler at all — the menu opened, looked complete, and could not be used. They are now real buttons with role="menuitem" and an onSelect callback.

The close control was an <X onClick> — a click handler on an SVG, so the only way out of the menu was a mouse. It is a labelled button now, and Escape closes from anywhere inside.

The trigger carries aria-haspopup="menu" and aria-expanded, and the menu is labelled by the heading it morphs from. Focus is handed to the first item on open and back to the trigger on close — the trigger unmounts during the morph, so without that hand-off focus falls to the document body.

Roving tabindex keeps the grid to one tab stop; arrows move within it, which is the menu contract people already know.

Under prefers-reduced-motion the scale and the plus-icon delay are dropped and the items cross-fade in place. The stagger is kept: it is 40ms per item and reads as sequence rather than movement.

Known limitation: vertical arrows wrap within a column rather than clamping, and with a non-multiple-of-columns item count the last row is short — so ↓ from the bottom of a short column lands on the first row rather than a non-existent cell. That is deliberate, but it is not what a spreadsheet does.

Performance

The trigger and the menu share a layoutId, so the container morphs rather than one element swapping for another — and the id is namespaced with useId, so two instances on a page cannot animate into each other. The label shares a second layoutId, which is what makes the heading appear to travel rather than re-render in place.

Animates transform and opacity only. Items use a 300/20 spring and a 40ms stagger, both kept verbatim from the original — the total is 240ms across six items, inside the 200ms-per-element budget once the stagger overlaps.

translateZ(0) on the morphing elements is retained: it forces a compositor layer up front, which avoids a first-frame hitch on the shared-layout transition.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

CreateMenu.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus, X } from "lucide-react";

import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface CreateMenuOption {
  id: string;
  label: string;
  icon: React.ReactNode;
  onSelect: () => void;
}

export interface CreateMenuProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onSelect"
> {
  options: readonly CreateMenuOption[];
  /** Text on the trigger, reused as the menu's heading. @default "Create New" */
  triggerLabel?: string;
  /** Grid width. Drives vertical arrow navigation. @default 3 */
  columns?: number;
  defaultOpen?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

/* -------------------------------------------------------------------------- */
/* Motion — values kept verbatim from the original                             */
/* -------------------------------------------------------------------------- */

const itemSpring = { type: "spring" as const, stiffness: 300, damping: 20 };
const STAGGER = 0.04;
/** The plus fades in after the container has finished morphing, not with it. */
const PLUS_DELAY = 0.21;

const gridVariants = {
  hidden: {},
  show: { transition: { staggerChildren: STAGGER } },
} as const;

const itemVariants = {
  hidden: { opacity: 0, scale: 0.8 },
  show: { opacity: 1, scale: 1 },
} as const;

const reducedItemVariants = {
  hidden: { opacity: 0 },
  show: { opacity: 1 },
} as const;

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A create button that becomes the things it can create.
 *
 * "New" does not say what you can make, and a dropdown list makes you read six
 * labels in sequence to find out. A grid of labelled icons is scannable in one
 * pass — you recognise the shape rather than reading the list. The trigger
 * morphing into the grid keeps the two connected, so it reads as the same
 * control opening rather than a panel arriving from elsewhere.
 */
export function CreateMenu({
  options,
  triggerLabel = "Create New",
  columns = 3,
  defaultOpen = false,
  open: controlledOpen,
  onOpenChange,
  className,
  ...rest
}: CreateMenuProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  // layoutId must be unique per document: two instances sharing "container"
  // would animate into each other.
  const containerId = `${uid}-container`;
  const headingId = `${uid}-heading`;
  const menuId = `${uid}-menu`;

  const triggerRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const interacted = useRef(false);

  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : uncontrolled;
  const [activeIndex, setActiveIndex] = useState(0);

  const setOpen = useCallback(
    (next: boolean) => {
      interacted.current = true;
      // Reset here rather than in the focus effect below. Opening is an event,
      // not a synchronisation, and setState in an effect body cascades a render.
      if (next) setActiveIndex(0);
      if (!isControlled) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  /*
   * The trigger unmounts when the menu opens, so focus has to be handed over
   * explicitly or it falls to the document body. Opening focuses the first
   * item — a menu that opens without moving focus leaves keyboard users with
   * nothing to act on.
   */
  useEffect(() => {
    if (!interacted.current) return;
    if (open) itemRefs.current[0]?.focus();
    else triggerRef.current?.focus();
  }, [open]);

  const focusItem = useCallback((index: number) => {
    setActiveIndex(index);
    itemRefs.current[index]?.focus();
  }, []);

  /** Grid navigation: left/right walk the list, up/down move by a row. */
  const handleItemKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
      const count = options.length;
      const last = count - 1;

      switch (event.key) {
        case "Escape":
          event.preventDefault();
          setOpen(false);
          return;
        case "ArrowRight":
          event.preventDefault();
          focusItem((index + 1) % count);
          return;
        case "ArrowLeft":
          event.preventDefault();
          focusItem((index - 1 + count) % count);
          return;
        case "ArrowDown":
          event.preventDefault();
          focusItem(index + columns > last ? index % columns : index + columns);
          return;
        case "ArrowUp":
          event.preventDefault();
          focusItem(
            index - columns < 0
              ? Math.min(index + columns * Math.floor(last / columns), last)
              : index - columns,
          );
          return;
        case "Home":
          event.preventDefault();
          focusItem(0);
          return;
        case "End":
          event.preventDefault();
          focusItem(last);
          return;
        default:
          return;
      }
    },
    [options.length, columns, focusItem, setOpen],
  );

  if (options.length === 0) return null;

  return (
    <div
      className={cn("flex items-center justify-center", className)}
      {...rest}
    >
      <AnimatePresence mode="popLayout">
        {!open ? (
          <motion.button
            key="trigger"
            ref={triggerRef}
            type="button"
            layoutId={containerId}
            aria-haspopup="menu"
            aria-expanded={false}
            onClick={() => setOpen(true)}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            style={{
              willChange: "transform",
              transform: "translateZ(0)",
              borderRadius: 20,
            }}
            className={cn(
              "focus-ring flex cursor-pointer items-center gap-1",
              "border-[3px] border-border bg-surface-subtle px-3 py-2.5 shadow-resting",
            )}
          >
            <motion.span
              aria-hidden="true"
              initial={{ opacity: 0 }}
              animate={{
                opacity: 1,
                transition: { delay: reduce ? 0 : PLUS_DELAY },
              }}
            >
              <Plus size={18} />
            </motion.span>
            <motion.span
              layoutId={`${uid}-label`}
              className="text-sm font-semibold"
              style={{ willChange: "transform", transform: "translateZ(0)" }}
            >
              {triggerLabel}
            </motion.span>
          </motion.button>
        ) : (
          <motion.div
            key="menu"
            layoutId={containerId}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            style={{
              willChange: "transform",
              transform: "translateZ(0)",
              borderRadius: 20,
            }}
            className="w-[260px] border-[3px] border-border bg-surface-subtle pt-2 shadow-overlay"
          >
            <div className="flex items-center justify-between px-3">
              <motion.span
                id={headingId}
                layoutId={`${uid}-label`}
                className="text-sm font-semibold"
                style={{ willChange: "transform", transform: "translateZ(0)" }}
              >
                {triggerLabel}
              </motion.span>

              {/*
                A real button. This was an <X onClick> — an SVG with a click
                handler, so the only way out of the menu was a mouse.
              */}
              <button
                type="button"
                onClick={() => setOpen(false)}
                aria-label={`Close ${triggerLabel.toLowerCase()}`}
                // Not `bg-border-strong` with inverse text: that put white on
                // #adb5bd at 2.07:1. `border-strong` is a line token — as a
                // fill behind a glyph it has nowhere near the contrast.
                className="focus-ring grid size-5 cursor-pointer place-items-center rounded-full bg-surface-active text-content"
              >
                <X className="size-3.5" aria-hidden="true" />
              </button>
            </div>

            <motion.div
              role="menu"
              id={menuId}
              aria-labelledby={headingId}
              className="mt-2 grid gap-4 rounded-xl bg-surface p-2"
              style={{
                gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
              }}
              initial="hidden"
              animate="show"
              variants={gridVariants}
            >
              {options.map((option, index) => (
                <motion.button
                  key={option.id}
                  ref={(node) => {
                    itemRefs.current[index] = node;
                  }}
                  type="button"
                  role="menuitem"
                  // Roving tabindex: the menu is one tab stop, arrows move within.
                  tabIndex={index === activeIndex ? 0 : -1}
                  onClick={() => {
                    option.onSelect();
                    setOpen(false);
                  }}
                  onKeyDown={(event) => handleItemKeyDown(event, index)}
                  variants={reduce ? reducedItemVariants : itemVariants}
                  transition={reduce ? { duration: 0.01 } : itemSpring}
                  className={cn(
                    "focus-ring flex cursor-pointer flex-col items-center gap-2",
                    "rounded-lg p-2 text-content-muted will-change-transform",
                    "hover:bg-surface-subtle",
                  )}
                >
                  <span aria-hidden="true" className="[&>svg]:size-5">
                    {option.icon}
                  </span>
                  <span className="text-xs">{option.label}</span>
                </motion.button>
              ))}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

export default CreateMenu;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react)
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by Dmitry Elisov. Rebuilt with a props API, working options, a keyboard-operable menu following the WAI-ARIA menu pattern, focus hand-off, and reduced-motion handling. The morph, stagger and spring are unchanged.