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
npm install motion lucide-reactThen copy CreateMenu.tsx. It depends only on @/lib/cn.
Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
options | readonly CreateMenuOption[] | — | The items. Empty renders nothing. |
triggerLabel | string | "Create New" | Trigger text, reused as the menu heading. |
columns | number | 3 | Grid width. Drives vertical arrow navigation. |
defaultOpen | boolean | false | Uncontrolled initial state. |
open | boolean | uncontrolled | Controlled state. Pass with onOpenChange. |
onOpenChange | (open: boolean) => void | — | Fires on every toggle. |
className | string | — | Merged onto the root. |
CreateMenuOption: { id: string; label: string; icon: ReactNode; onSelect: () => void }.
Keyboard
| Key | Action |
|---|---|
Enter / Space | Open 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 / End | First or last item |
Escape | Close 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.
"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
| Framework | React 19 |
| Motion | Motion 12 (motion/react) |
| Styling | Tailwind CSS v4 |
| Icons | lucide-react |
| Types | TypeScript 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.