Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
NavigationUpdatedFree

Menu Item

Menu item primitive.

Preview

Live preview
@oshon-ai/components

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add menuitem

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { MenuItem } from '@oshon-ai/components';

export default function Example() {
  return <MenuItem />;
}

Interactive — all features, 3 tiers

Last action:
Status: active
Compact: on
Show archived: off
Click Row actions ▾ to open. The demo exercises every Menu feature at once:
  • Grouped sections (Actions, Views, Status)
  • Leading icons on each item
  • Keyboard-hint keycaps (⌘E, ⌘⇧S, )
  • Inline toggles (Compact view, Show archived) — flipping does not close the menu
  • Single-select "selected" state inside Status
  • Multi-line items (Pricing rule) with their own submenus
  • Three-tier cascade (Share → Assign to → searchable teammate list)
  • Disabled item (Delete)
Keyboard: opens a submenu, collapses it,Esc dismisses the whole tree. Picking any leaf fires onAction and closes the menu.
tsx
<InteractivePlayground />

Auto-search density (≥13 items)

Assigned to:
This list has 20 items — above the default threshold of 13 — so Menu renders an inline search field at the top automatically. Type any portion of a name to filter case-insensitively. Try z to see the empty state.
Override the cutoff with autoSearchThreshold, or force the field on/off with searchable.
tsx
<AutoSearchPlayground />

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
items*MenuEntry[]Items to render (mix of item, divider, and group entries).
aria-labelstringAccessible label for the menu container.
aria-labelledbystringId of the element that labels the menu (alternative to aria-label).
autoSearchThresholdnumber13Item-count cutover for `searchable: 'auto'`. When the leaf-item count is STRICTLY greater than this, the search field appears. Default `13` — empirically the point where vertical scanning starts to slow down vs. typing a query. Counts nested submenu items recursively so a top-level menu with 6 groups × 4 children still triggers search.
classNamestringMerged onto the outer container.
idstringDOM id for the menu container.
onAction((itemId: string) => void)Fires when any leaf item (item without children, not a toggle) is selected anywhere in the menu tree. Receives the item id (or its array index if no id). Complements per-item `onSelect`. Bubbles from submenus to the root.
onEscape(() => void)Fires when Escape is pressed anywhere in the tree. The menu does NOT close itself — its parent popover/portal owns visibility.
onSearch((query: string) => void)Fires on every search keystroke. Consumer filters `items`.
searchableboolean | "auto"autoControls the inline search affordance at the top of the menu. - `true` — always show the search field - `false` — never show it - `'auto'` — show when the leaf-item count exceeds `autoSearchThreshold` (data-density scaling) - undefined — same as `'auto'` Default: `'auto'`. Lets a 5-item context menu stay tidy while a 30-item user-list menu opts users into filter-first navigation automatically.
searchDefaultValuestringUncontrolled initial search value.
searchPlaceholderstringSearch…Search field placeholder. Default `'Search…'`.
searchValuestringControlled search value.
sizeenummVisual size. Default `'m'`.
widthstring | numberFixed width override. Accepts a CSS length.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<MenuItem
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop