Menubar
A horizontal bar of application-style menus with keyboard navigation between them, built on the Radix Menubar primitive.
Preview
Saved
Pebble notes · Tuesday · 412 words
Kiosk interviews, round two
Ines ran four sessions at the station kiosk. Three people missed the return button on the first try and tapped the map header instead.
Tomás suggests a larger back target and a short label under it; Priya will mock both before Thursday.
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/menubar.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | string | - | On Menubar. The value of the open MenubarMenu; empty string means all closed. |
| onValueChange | (value: string) => void | - | On Menubar. Called when a menu opens, switches or closes. |
| loop | boolean | true | On Menubar. Arrow keys wrap between the first and last trigger. |
| value | string | auto | On MenubarMenu. Identifies the menu for controlled use. |
| align / sideOffset / alignOffset | Radix placement props | "start" / 8 / -4 | On MenubarContent. Places the panel just below the bar, flush with its edge. |
| variant | "default" | "danger" | "default" | On MenubarItem. Danger tints the row for destructive actions. |
| inset | boolean | false | On MenubarItem, MenubarLabel and MenubarSubTrigger. Aligns text with checkbox and radio rows. |
| onSelect | (event: Event) => void | - | On items. Call event.preventDefault() to keep the menu open. |
| checked / onCheckedChange | boolean, (checked: boolean) => void | - | On MenubarCheckboxItem (controlled only). |
| value / onValueChange | string, (value: string) => void | - | On MenubarRadioGroup (controlled only). |
| className | string | - | Merged last on every part, so it wins. |
Build notes
Built on the Radix Menubar primitive: the root owns which menu is open (a string `value`), roving focus across triggers, and the hover-to-switch behavior once a menu is open; each `MenubarMenu` reuses the DropdownMenu internals (focus management, typeahead, submenu pointer-grace) and exposes `--radix-menubar-content-*` variables. The bar and trigger styling is described in the Pebble paragraph below; triggers keep the shared focus ring on `focus-visible`. Panels and rows are visually and API-identical to this library's `menu`: the same part set and prop names, the same `row` and `panel` class strings, the same inline check and chevron SVGs, the same radio dot and the same `danger` variant. Those strings are copied into the file rather than imported from `menu`, so `menubar` installs on its own. `MenubarContent` defaults to `align="start"`, `sideOffset={8}`, `alignOffset={-4}` so the panel sits just below the bar with its left edge flush to the bar's border, and has `min-w-48`; height is capped by `--radix-menubar-content-available-height`; `transform-origin` comes from `--radix-menubar-content-transform-origin`. Content and sub-content are wrapped in a Portal. Animation reuses the `menu-in` (opacity 0, scale .95 to 1; `--motion-base`, enter easing) and `menu-out` (reverse; `--motion-fast`, exit easing) keyframes, shipped again in this item's registry `css` with identical bodies so installing both items is harmless; they are keyed on `data-state` under `motion-safe:`. Unlike the source: `danger` replaces "destructive", the label is small muted text (matching `menu`), the indicator slot is `size-4`, the trigger gets a visible focus ring and hover fill, and checkbox items are controlled only.
**Pebble (2026-11-03).** Restyled in lockstep with `menu` and `context-menu` (same copied `row`, `rowIcons` and `panel` strings). The bar is a raised Pebble surface: `bg-card`, `rounded-lg`, `ring-1 ring-hairline`, `shadow-raised-xs`, `inset-shadow-highlight`, no border. Triggers are compact (`h-7`, `px-2.5`, `rounded-md`) with muted text that goes to ink on hover and open over a `bg-muted` fill, eased over `--motion-fast`, and use the shared focus-ring recipe. Panels drop the border for the hairline ring and 1 px top light, with `shadow-raised-md` on `MenubarContent` and `shadow-raised-lg` on `MenubarSubContent`. Rows are `min-h-8` with a `bg-muted` + ink highlight; ember is reserved for the indicators (`text-accent` check, 6 px ember dot with a `ring-accent/20` halo, plus `forced-colors:bg-[CanvasText]` because forced colors drop backgrounds and rings). Labels are 12 px muted, shortcuts 11 px mono `tabular-nums`; the submenu chevron nudges 2 px toward the open submenu. `menu-in`/`menu-out` keep their names and bodies but run on tokens (`--motion-base` + `--motion-enter` in, `--motion-fast` + `--motion-exit` out). The demo is a notes-app window: the bar sits in the window chrome beside a saved status, and the View menu's checkbox and radio items visibly change the document below (line wrap, an SVG ruler, page width).
<!-- PEBBLE-LICENSE -->View source
registry/overlay/menubar/menubar.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: menubar.
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 { Menubar as MenubarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
// Pebble rows, kept identical to the Menu component's (copied, not imported,
// so this file installs on its own): compact 32 px rows, a muted roving
// highlight with ink text, icons that brighten from muted to ink with it.
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;
// elevation is added per panel: md for menus, lg for submenus). 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";
export function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"flex h-9 items-center gap-0.5 rounded-lg bg-card p-1 text-card-foreground shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline forced-colors:border forced-colors:border-[CanvasText]",
className,
)}
{...props}
/>
);
}
export function MenubarMenu(props: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
export function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex h-7 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-colors duration-(--motion-fast) ease-enter outline-none select-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 data-[state=open]:bg-muted data-[state=open]:text-foreground motion-reduce:transition-none forced-colors:data-[state=open]:outline-2 forced-colors:data-[state=open]:outline-[Highlight] forced-colors:data-[state=open]:outline-solid [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
export function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
panel,
"max-h-(--radix-menubar-content-available-height) min-w-48 origin-(--radix-menubar-content-transform-origin) shadow-raised-md",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
);
}
export function MenubarGroup(props: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
export type MenubarItemProps = React.ComponentProps<typeof MenubarPrimitive.Item> & {
variant?: "default" | "danger";
inset?: boolean;
};
export function MenubarItem({ variant = "default", inset, className, ...props }: MenubarItemProps) {
return (
<MenubarPrimitive.Item
data-slot="menubar-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 MenubarCheckboxItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(row, "pl-8", className)}
{...props}
>
<span data-slot="menubar-item-indicator" className={indicator}>
<MenubarPrimitive.ItemIndicator>
<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>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
export function MenubarRadioGroup(props: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />;
}
export function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(row, "pl-8", className)}
{...props}
>
<span data-slot="menubar-item-indicator" className={indicator}>
<MenubarPrimitive.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]" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
export function MenubarLabel({
inset,
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & { inset?: boolean }) {
return (
<MenubarPrimitive.Label
data-slot="menubar-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 MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
export function MenubarShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ml-auto pl-4 font-mono text-[11px] tracking-wide text-muted-foreground tabular-nums",
className,
)}
{...props}
/>
);
}
export function MenubarSub(props: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
export function MenubarSubTrigger({
inset,
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-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>
</MenubarPrimitive.SubTrigger>
);
}
export function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.Portal>
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
panel,
"origin-(--radix-menubar-content-transform-origin) shadow-raised-lg",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
);
}
