Menu Panel
A button with a panel under it that opens when you point at it, click it or press Enter.
$npx shadcn@latest add https://framebrick.co/r/menu-panel.jsonFrom the Kendi template, see it in context.
import { MenuPanel, MenuPanelColumn } from "@/components/ui/menu-panel";
const categories = [
{ name: "Travel", text: "Places worth the journey, and the ones worth skipping." },
{ name: "Food", text: "Recipes, restaurants and the stories behind them." },
{ name: "Design", text: "Objects, rooms and the people who make them." },
];
const posts = ["The quiet side of the harbour", "Ten bowls, one kitchen", "A chair that lasts"];
/* Point at the button (or click it, or press Enter): the panel fades in and its columns slide in one after the other. */
export default function MenuPanelDemo() {
return (
<div className="flex w-full items-start justify-center">
<MenuPanel label="Categories" panelClassName="grid w-[min(34rem,calc(100vw-3rem))] grid-cols-[1fr_auto_1fr] gap-6">
<MenuPanelColumn className="flex flex-col gap-3">
<p className="text-xs text-foreground/75">Categories</p>
{categories.map((category) => (
<a key={category.name} href="#" className="flex flex-col rounded-md text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<span className="font-medium">{category.name}</span>
<span className="text-foreground/75">{category.text}</span>
</a>
))}
</MenuPanelColumn>
<div aria-hidden className="w-px bg-border" />
<MenuPanelColumn className="flex flex-col gap-3">
<p className="text-xs text-foreground/75">Spotlight</p>
{posts.map((post) => (
<a key={post} href="#" className="rounded-md text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
{post}
</a>
))}
</MenuPanelColumn>
</MenuPanel>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/menu-panel.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/menu-panel.tsx."use client"; import * as React from "react"; import { motion, type Transition, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; /* * Menu Panel — a button with a panel under it that opens when a mouse points at the button, when it is clicked or tapped, or when * the keyboard activates it (from the Kendi header "Categories" menu: a 720px panel with the categories and a Spotlight). The panel * stays open while the pointer is on the button or on the panel — the 10px between them belongs to the panel, so crossing it does * not close it — and closes when the pointer leaves both, on Escape (the focus goes back to the button), when the focus leaves, or * on a press outside. What is in the panel is yours; give each column a <MenuPanelColumn>, so the columns come in one after the * other. * * Framer's: the panel fades in on spring bounce 0.2, 0.4s, and each column fades from 0.001 and slides in from 20px to the left on * a 0.4s tween, cubic-bezier(0.44, 0, 0.56, 1). Kendi opens it by hover on a desktop and by click on a tablet, by window width; * here it is the input that decides, not the window: a mouse opens it by pointing and a touch or a key by activating, so a laptop * with a touch screen and a tablet with a mouse both work. The button says whether it is open (aria-expanded) and its caret turns. * * The panel is in the page when it is closed (display none and inert, so its links are in the HTML but not in the way) and only the * opening is animated. The slide is a transform, which <MotionConfig reducedMotion="user"> skips; the fades are opacity, which * stay with reduced motion. It is a menu of links, not a dialog: the focus is not trapped, and Tab moves on through the panel. */ const panelSpring: Transition = { type: "spring", bounce: 0.2, duration: 0.4 }; const column: Variants = { closed: { opacity: 0.001, x: -20 }, open: { opacity: 1, x: 0, transition: { duration: 0.4, ease: [0.44, 0, 0.56, 1] } }, }; /** One column of the panel: it fades in and slides in from the left when the panel opens. */ export function MenuPanelColumn({ className, children }: { className?: string; children: React.ReactNode }) { return ( <motion.div variants={column} className={className}> {children} </motion.div> ); } export type MenuPanelProps = { /** The button's content. */ label: React.ReactNode; /** What is in the panel: your columns. */ children: React.ReactNode; /** Classes for the button. */ className?: string; /** Classes for the panel: its width, padding, layout. */ panelClassName?: string; /** Where the panel sits under the button: centred on it, or from its left edge. */ align?: "center" | "start"; /** A caret after the label that turns up while the menu is open. */ caret?: boolean; }; export function MenuPanel({ label, children, className, panelClassName, align = "center", caret = true }: MenuPanelProps) { const [open, setOpen] = React.useState(false); /* The panel is drawn from the moment it opens until its fade out ends, and hidden after. */ const [present, setPresent] = React.useState(false); const root = React.useRef<HTMLDivElement>(null); const button = React.useRef<HTMLButtonElement>(null); const lastPointer = React.useRef<string>(""); const id = React.useId(); function show() { setPresent(true); setOpen(true); } /* A press outside, and the focus leaving, close it. */ React.useEffect(() => { if (!open) return; const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); }, [open]); return ( <div ref={root} className="relative inline-flex" onPointerEnter={(event) => { lastPointer.current = event.pointerType; if (event.pointerType === "mouse") show(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(false); }} onBlur={(event) => { if (!root.current?.contains(event.relatedTarget as Node | null)) setOpen(false); }} onKeyDown={(event) => { if (event.key === "Escape" && open) { setOpen(false); button.current?.focus(); } }} > <button ref={button} type="button" aria-expanded={open} aria-controls={id} onPointerDown={(event) => { lastPointer.current = event.pointerType; }} onKeyDown={() => { lastPointer.current = ""; }} onClick={() => { /* A mouse already opened it by pointing: its click should not close it again. */ if (lastPointer.current === "mouse" && open) return; if (open) setOpen(false); else show(); }} className={cn( "inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 py-1 text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} > {label} {caret && ( <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className={cn("size-3.5 shrink-0 transition-transform duration-300 motion-reduce:transition-none", open && "-scale-y-100")} > <path d="M4 6l4 4 4-4" /> </svg> )} </button> {/* The 10px between the button and the panel is the panel's own padding, so the pointer crossing it is still on the menu. */} <div id={id} inert={!open} className={cn( "absolute top-full z-30 pt-2.5", align === "center" ? "left-1/2 -translate-x-1/2" : "left-0", !present && "hidden", )} > <motion.div initial={false} animate={open ? "open" : "closed"} variants={{ closed: { opacity: 0 }, open: { opacity: 1 } }} transition={panelSpring} onAnimationComplete={(definition) => { if (definition === "closed") setPresent(false); }} className={cn("rounded-xl border bg-popover p-6 text-popover-foreground shadow-lg", panelClassName)} > {children} </motion.div> </div> </div> ); }
Usage
import { MenuPanel, MenuPanelColumn } from "@/components/ui/menu-panel";
<MenuPanel label="Categories" panelClassName="grid w-[34rem] grid-cols-2 gap-6">
<MenuPanelColumn>…the categories…</MenuPanelColumn>
<MenuPanelColumn>…the spotlight…</MenuPanelColumn>
</MenuPanel>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | The button's content. |
| children | ReactNode | — | What is in the panel: your columns. Wrap each in MenuPanelColumn to have them come in one after the other. |
| align | "center" | "start" | "center" | Where the panel sits under the button: centred on it, or from its left edge. |
| caret | boolean | true | A caret after the label that turns up while the menu is open. |
| className | string | — | Classes for the button. |
| panelClassName | string | — | Classes for the panel: its width, padding, layout. |
- It is Kendi's header "Categories" menu: a 720 px panel, 10 px under the button, centred on it, with the categories at the left and a Spotlight at the right. Framer: the panel fades in on spring bounce 0.2, 0.4 s, and each column fades from 0.001 and slides in from 20 px to the left on a 0.4 s tween, cubic-bezier(0.44, 0, 0.56, 1).
- Kendi opens it by hover on a desktop and by click on a tablet, by window width. Here the input decides: a mouse opens it by pointing, a touch or a key by activating, so a laptop with a touch screen and a tablet with a mouse both work. The panel stays open while the pointer is on the button or the panel (the 10 px between them is the panel's own padding), and closes when the pointer leaves both, on Escape (the focus returns to the button), when the focus leaves, or on a press outside.
- The button has aria-expanded and aria-controls, and its caret turns. It is a menu of links, not a dialog: the focus is not trapped and Tab moves on through the panel. The panel is in the HTML when it is closed, hidden and inert, so its links are there for a search engine but not in the way; only the opening is animated.
- The slide is a transform, which <MotionConfig reducedMotion="user"> skips; the fades are opacity and stay with reduced motion.
- Text in the panel needs 4.5:1 on bg-popover: the secondary text of the demo is foreground at 75%.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called MenuPanel, a client component, with a helper MenuPanelColumn.
- MenuPanel props: label (the button's content), children (the panel's content), align ("center" | "start"), caret (true), className, panelClassName. A relative inline-flex wrapper holds a button (type button, aria-expanded, aria-controls the panel's id, inline-flex items-center gap-1.5 text-sm font-medium, ring-[3px] ring-ring/50 on focus-visible, a 14px caret svg that flips with -scale-y-100 while open) and the panel.
- The panel is an absolute top-full z-30 div with pt-2.5 (the gap is its own padding), centred with left-1/2 -translate-x-1/2 or left-0, holding a motion.div (rounded-xl border bg-popover p-6 text-popover-foreground shadow-lg plus panelClassName). It is always in the page: display: none and inert when closed. The motion.div animates opacity 0 to 1 with a spring (bounce 0.2, duration 0.4s) between variants closed and open; keep a state "present" that is true from opening until the closed animation completes (onAnimationComplete), and add hidden while it is false.
- Open: a mouse pointerenter on the wrapper opens it and pointerleave closes it (pointerType mouse only); a click or tap, or Enter or Space, toggles it, but a click that follows a mouse opening it by pointing is ignored. Escape closes it and focuses the button; a blur whose relatedTarget is outside closes it; a pointerdown outside the wrapper closes it.
- MenuPanelColumn is a motion.div with variants closed { opacity: 0.001, x: -20 } and open { opacity: 1, x: 0 } on a 0.4s tween with ease [0.44, 0, 0.56, 1], inheriting the variant from the panel.










