Menu
A button-anchored menu of actions with checkbox, radio and submenu items, built on the Radix DropdownMenu primitive.
Preview
Planning · Board
Spring launch
Doing6
Review3
Done14
INMAPR
23 cards · edited 4 min ago by Malik
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/menu.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| open / defaultOpen | boolean | - | Menu: controlled or initial open state. |
| onOpenChange | (open: boolean) => void | - | Menu: called when the menu opens or closes. |
| modal | boolean | true | Menu: block interaction outside while open. |
| side / align / sideOffset | Radix placement | bottom / center / 6 | MenuContent: placement relative to the trigger. |
| variant | "default" | "danger" | "default" | MenuItem: danger styles destructive actions. |
| inset | boolean | false | MenuItem, MenuLabel, MenuSubTrigger: indent to line up with checkbox/radio rows. |
| onSelect | (event: Event) => void | - | Items: runs on choose; preventDefault keeps the menu open. |
| checked / onCheckedChange | boolean, handler | - | MenuCheckboxItem: controlled checked state. |
| value / onValueChange | string, handler | - | MenuRadioGroup: controlled selected value. |
| disabled | boolean | false | Items: skip and dim the item. |
Build notes
Built on the Radix DropdownMenu primitive (imported as `DropdownMenu as MenuPrimitive`), which provides focus management, typeahead, roving highlight, submenu pointer-grace and the `--radix-dropdown-menu-content-*` variables. `MenuContent` and `MenuSubContent` are wrapped in a Portal so they escape overflow and stacking contexts. The panel surface is described in the Pebble paragraph below; its height is capped by `--radix-dropdown-menu-content-available-height` and it scrolls vertically. `transform-origin` is set from `--radix-dropdown-menu-content-transform-origin` so the scale grows out of the trigger. Animation is two keyframes shipped in the registry item's `css`: `menu-in` (opacity 0, scale .95 to opacity 1, scale 1; `--motion-base` with the enter easing) and `menu-out` (the reverse; `--motion-fast` with the exit easing), keyed on `data-state` under `motion-safe:`; Radix's presence waits for `menu-out` to finish before unmounting. All rows share one class string (padding, `focus:bg-muted`, disabled opacity, SVG sizing); checkbox and radio rows add `pl-8` and an absolutely positioned indicator slot. The submenu chevron flips with `rtl:rotate-180`. Unlike the source, there is no uncontrolled `defaultChecked` on checkbox items: Radix exposes them as controlled only, so the demo holds state with `useState`. **Pebble (2026-11-03).** The panel is now a raised Pebble surface: `bg-card`, `ring-1 ring-hairline`, `inset-shadow-highlight` and `rounded-lg`, with no border; elevation is added per panel (`shadow-raised-md` on `MenuContent`, `shadow-raised-lg` on `MenuSubContent` so a submenu reads as sitting above its parent). `sideOffset` defaults to 6. Rows are compact (`min-h-8`, `rounded-sm`) and the roving highlight is `bg-muted` + `text-foreground`, eased with `duration-(--motion-fast) ease-enter`; row icons sit in `text-muted-foreground` and brighten to ink with the highlight (or while a submenu is open). Ember is reserved for small indicators: the checkbox check is `text-accent`, the radio indicator a 6 px ember dot with a 3 px `ring-accent/20` halo; because forced-colors mode drops backgrounds and rings, the dot also carries `forced-colors:bg-[CanvasText]`. Labels are 12 px muted; shortcuts are 11 px mono `tabular-nums`. The submenu chevron nudges 2 px toward the open submenu (`group-data-[state=open]/sub:translate-x-0.5`, mirrored in RTL, off under reduced motion). Motion keeps the shipped `menu-in`/`menu-out` keyframes but runs them on tokens: `menu-in` over `--motion-base` on `--motion-enter`, `menu-out` over `--motion-fast` on `--motion-exit`, still only under `motion-safe:`. The demo is a board card whose header holds two triggers: a secondary "View" button (checkbox and radio items) and a ghost icon button (icons, shortcuts, a submenu with a disabled row, a disabled item and a danger item). <!-- PEBBLE-LICENSE -->
View source
registry/overlay/menu/menu.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: menu.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { DropdownMenu as MenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
// Pebble rows: compact (32 px), the roving highlight is a muted whisper with
// ink text, and icons brighten from muted to ink with it. Shared by items,
// checkbox/radio items and the submenu trigger.
const row =
"relative flex min-h-8 cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none transition-colors duration-(--motion-fast) ease-enter motion-reduce:transition-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 forced-colors:focus:outline-2 forced-colors:focus:outline-solid forced-colors:focus:outline-[Highlight]";
const rowIcons =
"[&_svg:not([class*='text-'])]:text-muted-foreground focus:[&_svg:not([class*='text-'])]:text-foreground data-[state=open]:[&_svg:not([class*='text-'])]:text-foreground";
// The floating panel: a raised Pebble surface (hairline ring, 1 px top light;
// the elevation is added per panel: md for menus, lg for submenus so they sit
// above their parent). It scales in from the trigger (menu-in, 0.95 to 1) over
// --motion-base on the enter curve and fades out over --motion-fast on the exit
// curve; both run only under motion-safe and are keyed on data-state, so
// Radix's presence waits for the exit keyframe to finish.
const panel =
"z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-lg bg-card p-1 text-card-foreground ring-1 ring-hairline inset-shadow-highlight forced-colors:border forced-colors:border-[CanvasText] motion-safe:data-[state=closed]:animate-[menu-out_var(--motion-fast)_var(--motion-exit)] motion-safe:data-[state=open]:animate-[menu-in_var(--motion-base)_var(--motion-enter)]";
const indicator =
"pointer-events-none absolute left-2 flex size-4 items-center justify-center text-accent";
function Check() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-4"
>
<path d="m3.5 8.5 3 3 6-7" />
</svg>
);
}
export function Menu(props: React.ComponentProps<typeof MenuPrimitive.Root>) {
return <MenuPrimitive.Root data-slot="menu" {...props} />;
}
export function MenuTrigger(props: React.ComponentProps<typeof MenuPrimitive.Trigger>) {
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />;
}
export function MenuContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof MenuPrimitive.Content>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Content
data-slot="menu-content"
sideOffset={sideOffset}
className={cn(
panel,
"max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) shadow-raised-md",
className,
)}
{...props}
/>
</MenuPrimitive.Portal>
);
}
export function MenuGroup(props: React.ComponentProps<typeof MenuPrimitive.Group>) {
return <MenuPrimitive.Group data-slot="menu-group" {...props} />;
}
export type MenuItemProps = React.ComponentProps<typeof MenuPrimitive.Item> & {
variant?: "default" | "danger";
inset?: boolean;
};
export function MenuItem({ variant = "default", inset, className, ...props }: MenuItemProps) {
return (
<MenuPrimitive.Item
data-slot="menu-item"
data-variant={variant}
data-inset={inset || undefined}
className={cn(
row,
rowIcons,
"data-inset:pl-8",
// Danger keeps the strong text tone so it holds contrast on the card
// and on the tinted focus background.
"data-[variant=danger]:text-danger-strong data-[variant=danger]:focus:bg-danger/10 data-[variant=danger]:focus:text-danger-strong data-[variant=danger]:*:[svg]:text-danger-strong!",
className,
)}
{...props}
/>
);
}
export function MenuCheckboxItem({
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem>) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menu-checkbox-item"
className={cn(row, "pl-8", className)}
{...props}
>
<span data-slot="menu-item-indicator" className={indicator}>
<MenuPrimitive.ItemIndicator>
<Check />
</MenuPrimitive.ItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
export function MenuRadioGroup(props: React.ComponentProps<typeof MenuPrimitive.RadioGroup>) {
return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />;
}
export function MenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem>) {
return (
<MenuPrimitive.RadioItem
data-slot="menu-radio-item"
className={cn(row, "pl-8", className)}
{...props}
>
<span data-slot="menu-item-indicator" className={indicator}>
<MenuPrimitive.ItemIndicator className="flex">
{/* Ember dot with a soft halo: the selected thing, small. */}
<span className="size-1.5 rounded-full bg-current ring-[3px] ring-accent/20 forced-colors:bg-[CanvasText]" />
</MenuPrimitive.ItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
export function MenuLabel({
inset,
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Label> & { inset?: boolean }) {
return (
<MenuPrimitive.Label
data-slot="menu-label"
data-inset={inset || undefined}
className={cn(
"px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground data-inset:pl-8",
className,
)}
{...props}
/>
);
}
export function MenuSeparator({
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
return (
<MenuPrimitive.Separator
data-slot="menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
export function MenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="menu-shortcut"
className={cn(
"ml-auto pl-4 font-mono text-[11px] tracking-wide text-muted-foreground tabular-nums",
className,
)}
{...props}
/>
);
}
export function MenuSub(props: React.ComponentProps<typeof MenuPrimitive.Sub>) {
return <MenuPrimitive.Sub data-slot="menu-sub" {...props} />;
}
export function MenuSubTrigger({
inset,
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<MenuPrimitive.SubTrigger
data-slot="menu-sub-trigger"
data-inset={inset || undefined}
className={cn(
row,
rowIcons,
"group/sub data-inset:pl-8 data-[state=open]:bg-muted data-[state=open]:text-foreground",
className,
)}
{...props}
>
{children}
{/* The chevron leans toward the submenu while it is open. */}
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="ml-auto size-4 transition-transform duration-(--motion-fast) ease-enter group-data-[state=open]/sub:translate-x-0.5 motion-reduce:transition-none rtl:rotate-180 rtl:group-data-[state=open]/sub:-translate-x-0.5"
>
<path d="m6 4 4 4-4 4" />
</svg>
</MenuPrimitive.SubTrigger>
);
}
export function MenuSubContent({
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.SubContent>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.SubContent
data-slot="menu-sub-content"
className={cn(
panel,
"origin-(--radix-dropdown-menu-content-transform-origin) shadow-raised-lg",
className,
)}
{...props}
/>
</MenuPrimitive.Portal>
);
}
