Bloom Menu
Preview
TSXcomponents/previews/blocks/bloom-menu.preview.tsx
"use client";
import { BloomMenu } from "@/components/motion/bloom-menu";
export function BloomMenuPreview() {
return (
<div className="flex min-h-[420px] w-full items-start justify-center pt-24">
<BloomMenu />
</div>
);
}
TSXcomponents/motion/bloom-menu.tsx
"use client";
// beui.dev/components/blocks/bloom-menu
import {
Bell,
FileText,
FolderClosed,
LayoutGrid,
Link,
Plus,
Table,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ComponentType,
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
type MenuItem = { label: string; icon: ComponentType<{ className?: string }> };
const ITEMS: MenuItem[] = [
{ label: "Doc", icon: FileText },
{ label: "Board", icon: LayoutGrid },
{ label: "Table", icon: Table },
{ label: "Folder", icon: FolderClosed },
{ label: "Reminder", icon: Bell },
{ label: "Link", icon: Link },
];
// Folder-open feel: a touch of overshoot as the panel expands, kept subtle.
const SPRING_FOLDER = {
type: "spring",
stiffness: 300,
damping: 32,
mass: 0.9,
} as const;
export interface BloomMenuProps {
items?: MenuItem[];
onSelect?: (label: string) => void;
className?: string;
}
export function BloomMenu({
items = ITEMS,
onSelect,
className,
}: BloomMenuProps) {
const [open, setOpen] = useState(false);
const reduce = useReducedMotion();
const layoutId = useId();
const menuId = useId();
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
// Set when the menu closes holding focus: the trigger is unmounted while
// open, so focus can only go back once it has mounted again.
const restoreFocus = useRef(false);
// The roving tab stop, resolved during render. Opening starts on the first
// item; arrow keys walk the grid from there.
const rows = useMemo(
() => items.map((item) => ({ id: item.label })),
[items],
);
const { activeIndex, moveTo } = useRowCursor(rows, "");
useOnOpen(open, () => moveTo(null));
const activeId = rows[activeIndex]?.id;
const close = useCallback((returnFocus: boolean) => {
if (returnFocus && ref.current?.contains(document.activeElement))
restoreFocus.current = true;
setOpen(false);
}, []);
useEffect(() => {
if (open) {
if (activeId !== undefined)
itemRefs.current.get(activeId)?.focus({ preventScroll: true });
return;
}
if (!restoreFocus.current) return;
restoreFocus.current = false;
triggerRef.current?.focus();
}, [open, activeId]);
const COLS = 3;
const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const last = rows.length - 1;
const step: Record<string, number> = {
ArrowRight: 1,
ArrowLeft: -1,
ArrowDown: COLS,
ArrowUp: -COLS,
};
let next: number | null = null;
if (event.key in step) {
const target = activeIndex + step[event.key];
next = target < 0 || target > last ? activeIndex : target;
} else if (event.key === "Home") next = 0;
else if (event.key === "End") next = last;
if (next === null) return;
event.preventDefault();
moveTo(rows[next].id);
};
useEffect(() => {
if (!open) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") close(true);
};
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, close]);
const morph = reduce ? { duration: 0.15 } : SPRING_FOLDER;
return (
<div ref={ref} className={cn("relative inline-flex", className)}>
{/* spacer fixes the anchor to the trigger size */}
<div className="h-11 w-36" aria-hidden />
{/* Centering box sized to the OPEN panel and centered on the trigger.
place-items-center only centers an item that fits its cell, so the cell
must be as wide as the panel — otherwise the overflow left-anchors and
the panel expands rightward. The box is a fixed size per viewport (vw
doesn't change mid-animation), so its -translate centering never drifts
the way a content-sized wrapper would. Both states share its center, so
the morph grows from the middle outward in every direction. */}
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 grid h-[300px] w-[min(86vw,420px)] -translate-x-1/2 -translate-y-1/2 place-items-center [&>*]:pointer-events-auto">
{/* popLayout pulls the exiting trigger out of grid flow at once, so the
grid never briefly holds two rows and shoves the panel off-center */}
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
transition={morph}
// Focus leaving the open menu closes it, as a click outside does.
onBlur={(event) => {
const next = event.relatedTarget;
if (next instanceof Node && !ref.current?.contains(next))
close(false);
}}
style={{ borderRadius: 16 }}
className="w-[min(86vw,420px)] overflow-hidden border border-border bg-card"
>
<motion.div
// `layout` lets framer undo the box's morph scaling so this
// content stays crisp instead of stretching with the resize.
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reduce ? 0 : 0.12, duration: 0.2 }}
>
{/* header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span
id={`${menuId}-label`}
className="text-sm font-medium text-muted-foreground"
>
Create
</span>
<button
type="button"
onClick={() => close(true)}
aria-label="Close menu"
className="rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" />
</button>
</div>
{/* grid */}
<motion.div
// Iris reveal: start as a small box at the grid center and open
// outward to all four corners, so the menu grows from the middle
// in every direction instead of wiping top-down.
initial={
reduce ? false : { clipPath: "inset(45% 34% 45% 34%)" }
}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
transition={{
delay: reduce ? 0 : 0.08,
duration: 0.45,
ease: EASE_OUT,
}}
id={menuId}
role="menu"
aria-labelledby={`${menuId}-label`}
onKeyDown={onGridKeyDown}
className="grid grid-cols-3"
>
{items.map((item, i) => {
// Radial stagger: delay each item by its distance from the
// grid center so the four corners animate together and the
// open reads as center-out, not corner-by-corner.
const cols = 3;
const rows = Math.ceil(items.length / cols);
const col = i % cols;
const row = Math.floor(i / cols);
const dist = Math.hypot(
col - (cols - 1) / 2,
row - (rows - 1) / 2,
);
return (
<button
key={item.label}
ref={(node) => {
if (node) itemRefs.current.set(item.label, node);
else itemRefs.current.delete(item.label);
}}
type="button"
role="menuitem"
tabIndex={item.label === activeId ? 0 : -1}
onFocus={() => moveTo(item.label)}
onClick={() => {
onSelect?.(item.label);
close(true);
}}
// Static cell with hairline borders (no animated fill) so
// the grid lines never flicker as items stagger in. Only the
// inner content animates.
className={cn(
"flex items-center justify-center px-3 py-6 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:bg-muted/60 focus-visible:text-foreground",
i % 3 !== 2 && "border-r border-border",
i < 3 && "border-b border-border",
)}
>
<motion.span
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.85, filter: "blur(6px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={{
delay: reduce ? 0 : 0.1 + dist * 0.07,
type: "spring",
stiffness: 440,
damping: 34,
}}
className="flex flex-col items-center gap-2"
>
<item.icon className="h-5 w-5" />
<span className="text-sm font-medium">{item.label}</span>
</motion.span>
</button>
);
})}
</motion.div>
</motion.div>
</motion.div>
) : (
<motion.button
key="trigger"
ref={triggerRef}
type="button"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
onClick={() => setOpen(true)}
aria-haspopup="menu"
aria-expanded={open}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
}
}}
whileTap={reduce ? undefined : { scale: 0.97 }}
className="inline-flex h-11 w-36 items-center justify-center border border-border bg-card text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{/* own `layout` counter-scales the label so it stays crisp while the
button box morphs, instead of stretching with it */}
<motion.span
layout
className="inline-flex items-center gap-2 whitespace-nowrap"
>
Create
<Plus className="h-4 w-4" />
</motion.span>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/bloom-menubloom-menubloom-menu
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
/** Accelerating curve for things that fall (a dropped ball, a toppled block). Not for UI entrances. */
export const EASE_IN = [0.55, 0, 1, 0.45] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/hooks/use-on-open.ts
"use client";
import { useState } from "react";
/**
* Runs `start` during the render in which `open` becomes true.
*
* Opening a list starts a fresh session — an empty query, the highlight back at
* the top — and that is a resolution, not a side effect: a passive effect runs
* after the commit, so the new session would carry the last one's state for a
* window in which a key press can land.
*
* `start` may only set state belonging to the calling component. Anything that
* reaches outside it — a consumer's callback, a DOM write, moving focus — is a
* side effect, and React rejects a callback that sets another component's state
* during this one's render. Put those in an effect keyed to `open`.
*/
export function useOnOpen(open: boolean, start: () => void) {
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) start();
}
}
TSXlib/hooks/use-row-cursor.ts
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/**
* Where the keyboard or the pointer last moved to: the row's id, stamped with
* the query it was placed under.
*/
type RowCursor = { id: string; query: string };
/** The cursor's row, or -1 once the query has moved on or the row has left. */
function indexOfCursor(
rows: readonly { id: string }[],
query: string,
cursor: RowCursor | null,
) {
if (cursor === null || cursor.query !== query) return -1;
return rows.findIndex((row) => row.id === cursor.id);
}
/**
* The highlighted row of a list whose rows can change under it.
*
* Which row is highlighted is resolved during render, never in a passive
* effect: a passive effect runs after the commit, so a list that had just
* changed would carry an `aria-activedescendant` naming a row that has left it,
* and a key pressed in that window would commit the wrong row or nothing at
* all.
*
* The cursor holds the row's id, not its position. A position alone cannot tell
* a list that shrank from one that swapped its rows for a different set of the
* same length, and the second case is the one that silently hands Enter to a
* row the user never chose. A cursor whose row has left the list returns the
* highlight to the first row rather than to the nearest surviving one: the row
* the user aimed at is gone, and the first row is where a new query already
* puts the highlight.
*
* Pass the query the list is filtered by. The cursor is stamped with it and
* dropped when it changes, so a caller cannot forget to clear it — including a
* caller whose query arrives as a prop and so never runs its own handler. Rows
* that come back under a query that has moved on cannot revive it either.
* `moveTo(null)` is for deliberate resets, such as reopening the list.
*
* Both callbacks keep one identity for the life of the component, and read the
* rows and the query through a ref to do it. A caller will put them in an
* effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`
* rebuilt on every keystroke would re-run that effect on every keystroke.
*/
export function useRowCursor(rows: readonly { id: string }[], query: string) {
const [cursor, setCursor] = useState<RowCursor | null>(null);
// Written after commit, not during render: a render React discards or has not
// finished still runs the component body, and an event handler that read this
// in that window would stamp the cursor with a query the committed tree does
// not have.
const latest = useRef({ rows, query });
useLayoutEffect(() => {
latest.current = { rows, query };
});
const cursorRow = indexOfCursor(rows, query, cursor);
// Cleared rather than ignored: React re-runs this render with the cursor
// already gone, so rows that come back cannot revive a highlight the user has
// stopped aiming at.
if (cursor !== null && cursorRow < 0) setCursor(null);
const moveTo = useCallback(
(id: string | null) =>
setCursor(id === null ? null : { id, query: latest.current.query }),
[],
);
const moveActive = useCallback((direction: 1 | -1) => {
const { rows: live, query: liveQuery } = latest.current;
const last = live.length - 1;
if (last < 0) return;
// Steps from the row the cursor is really on, inside the update, so that
// two keys landing in one batch move two rows rather than one.
setCursor((current) => {
const at = Math.max(indexOfCursor(live, liveQuery, current), 0);
const next = Math.min(Math.max(at + direction, 0), last);
return { id: live[next].id, query: liveQuery };
});
}, []);
return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/bloom-menu.tsx
"use client";
// beui.dev/components/blocks/bloom-menu
import {
Bell,
FileText,
FolderClosed,
LayoutGrid,
Link,
Plus,
Table,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ComponentType,
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
type MenuItem = { label: string; icon: ComponentType<{ className?: string }> };
const ITEMS: MenuItem[] = [
{ label: "Doc", icon: FileText },
{ label: "Board", icon: LayoutGrid },
{ label: "Table", icon: Table },
{ label: "Folder", icon: FolderClosed },
{ label: "Reminder", icon: Bell },
{ label: "Link", icon: Link },
];
// Folder-open feel: a touch of overshoot as the panel expands, kept subtle.
const SPRING_FOLDER = {
type: "spring",
stiffness: 300,
damping: 32,
mass: 0.9,
} as const;
export interface BloomMenuProps {
items?: MenuItem[];
onSelect?: (label: string) => void;
className?: string;
}
export function BloomMenu({
items = ITEMS,
onSelect,
className,
}: BloomMenuProps) {
const [open, setOpen] = useState(false);
const reduce = useReducedMotion();
const layoutId = useId();
const menuId = useId();
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
// Set when the menu closes holding focus: the trigger is unmounted while
// open, so focus can only go back once it has mounted again.
const restoreFocus = useRef(false);
// The roving tab stop, resolved during render. Opening starts on the first
// item; arrow keys walk the grid from there.
const rows = useMemo(
() => items.map((item) => ({ id: item.label })),
[items],
);
const { activeIndex, moveTo } = useRowCursor(rows, "");
useOnOpen(open, () => moveTo(null));
const activeId = rows[activeIndex]?.id;
const close = useCallback((returnFocus: boolean) => {
if (returnFocus && ref.current?.contains(document.activeElement))
restoreFocus.current = true;
setOpen(false);
}, []);
useEffect(() => {
if (open) {
if (activeId !== undefined)
itemRefs.current.get(activeId)?.focus({ preventScroll: true });
return;
}
if (!restoreFocus.current) return;
restoreFocus.current = false;
triggerRef.current?.focus();
}, [open, activeId]);
const COLS = 3;
const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const last = rows.length - 1;
const step: Record<string, number> = {
ArrowRight: 1,
ArrowLeft: -1,
ArrowDown: COLS,
ArrowUp: -COLS,
};
let next: number | null = null;
if (event.key in step) {
const target = activeIndex + step[event.key];
next = target < 0 || target > last ? activeIndex : target;
} else if (event.key === "Home") next = 0;
else if (event.key === "End") next = last;
if (next === null) return;
event.preventDefault();
moveTo(rows[next].id);
};
useEffect(() => {
if (!open) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") close(true);
};
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, close]);
const morph = reduce ? { duration: 0.15 } : SPRING_FOLDER;
return (
<div ref={ref} className={cn("relative inline-flex", className)}>
{/* spacer fixes the anchor to the trigger size */}
<div className="h-11 w-36" aria-hidden />
{/* Centering box sized to the OPEN panel and centered on the trigger.
place-items-center only centers an item that fits its cell, so the cell
must be as wide as the panel — otherwise the overflow left-anchors and
the panel expands rightward. The box is a fixed size per viewport (vw
doesn't change mid-animation), so its -translate centering never drifts
the way a content-sized wrapper would. Both states share its center, so
the morph grows from the middle outward in every direction. */}
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 grid h-[300px] w-[min(86vw,420px)] -translate-x-1/2 -translate-y-1/2 place-items-center [&>*]:pointer-events-auto">
{/* popLayout pulls the exiting trigger out of grid flow at once, so the
grid never briefly holds two rows and shoves the panel off-center */}
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
transition={morph}
// Focus leaving the open menu closes it, as a click outside does.
onBlur={(event) => {
const next = event.relatedTarget;
if (next instanceof Node && !ref.current?.contains(next))
close(false);
}}
style={{ borderRadius: 16 }}
className="w-[min(86vw,420px)] overflow-hidden border border-border bg-card"
>
<motion.div
// `layout` lets framer undo the box's morph scaling so this
// content stays crisp instead of stretching with the resize.
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reduce ? 0 : 0.12, duration: 0.2 }}
>
{/* header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span
id={`${menuId}-label`}
className="text-sm font-medium text-muted-foreground"
>
Create
</span>
<button
type="button"
onClick={() => close(true)}
aria-label="Close menu"
className="rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" />
</button>
</div>
{/* grid */}
<motion.div
// Iris reveal: start as a small box at the grid center and open
// outward to all four corners, so the menu grows from the middle
// in every direction instead of wiping top-down.
initial={
reduce ? false : { clipPath: "inset(45% 34% 45% 34%)" }
}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
transition={{
delay: reduce ? 0 : 0.08,
duration: 0.45,
ease: EASE_OUT,
}}
id={menuId}
role="menu"
aria-labelledby={`${menuId}-label`}
onKeyDown={onGridKeyDown}
className="grid grid-cols-3"
>
{items.map((item, i) => {
// Radial stagger: delay each item by its distance from the
// grid center so the four corners animate together and the
// open reads as center-out, not corner-by-corner.
const cols = 3;
const rows = Math.ceil(items.length / cols);
const col = i % cols;
const row = Math.floor(i / cols);
const dist = Math.hypot(
col - (cols - 1) / 2,
row - (rows - 1) / 2,
);
return (
<button
key={item.label}
ref={(node) => {
if (node) itemRefs.current.set(item.label, node);
else itemRefs.current.delete(item.label);
}}
type="button"
role="menuitem"
tabIndex={item.label === activeId ? 0 : -1}
onFocus={() => moveTo(item.label)}
onClick={() => {
onSelect?.(item.label);
close(true);
}}
// Static cell with hairline borders (no animated fill) so
// the grid lines never flicker as items stagger in. Only the
// inner content animates.
className={cn(
"flex items-center justify-center px-3 py-6 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:bg-muted/60 focus-visible:text-foreground",
i % 3 !== 2 && "border-r border-border",
i < 3 && "border-b border-border",
)}
>
<motion.span
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.85, filter: "blur(6px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={{
delay: reduce ? 0 : 0.1 + dist * 0.07,
type: "spring",
stiffness: 440,
damping: 34,
}}
className="flex flex-col items-center gap-2"
>
<item.icon className="h-5 w-5" />
<span className="text-sm font-medium">{item.label}</span>
</motion.span>
</button>
);
})}
</motion.div>
</motion.div>
</motion.div>
) : (
<motion.button
key="trigger"
ref={triggerRef}
type="button"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
onClick={() => setOpen(true)}
aria-haspopup="menu"
aria-expanded={open}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
}
}}
whileTap={reduce ? undefined : { scale: 0.97 }}
className="inline-flex h-11 w-36 items-center justify-center border border-border bg-card text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{/* own `layout` counter-scales the label so it stays crisp while the
button box morphs, instead of stretching with it */}
<motion.span
layout
className="inline-flex items-center gap-2 whitespace-nowrap"
>
Create
<Plus className="h-4 w-4" />
</motion.span>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}
API Reference
PropTypeDefault
items?MenuItem[]Default
[
{ label: "Doc", icon: FileText },
{ label: "Board", icon: LayoutGrid },
{ label: "Table", icon: Table },
{ label: "Folder", icon: FolderClosed },
{ label: "Reminder", icon: Bell },
{ label: "Link", icon: Link },
]onSelect?((label: string) => void)Default
—className?stringDefault
—Related components
Expandable Tabs
Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.
Expandable Action Bar
Compact icon actions that expand into labeled controls on hover or focus with shared layout motion.
Overflow Actions
Connected pill rail for primary actions that springs open to reveal extra controls.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated