Klarhimmel UI

Menu

Stable

A list of actions behind a button. Arrow keys move, type-ahead jumps. Built on Radix Dropdown Menu.

npx shadcn add @klarhimmel/menu @klarhimmel/button

When to use

  • Use for

    Three or more actions on one thing: rename, duplicate, delete.

  • Not for

    Navigation between pages, or picking a value. Use links or a select.

  • Pairs with

    Button or Icon button as the trigger, Kbd hints.

Preview

menu.tsx
import { Theme } from '@/components/klarhimmel/theme/style';
import { Button } from '@/components/klarhimmel/ui/button';
import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from '@/components/klarhimmel/ui/menu';

export default function Example() {
  return (
    <Theme theme="brand">
      <Menu>
        <MenuTrigger asChild><Button variant="secondary">Actions</Button></MenuTrigger>
        <MenuContent>
          <MenuItem onSelect={() => console.log('rename')}>Rename</MenuItem>
          <MenuSeparator />
          <MenuItem danger onSelect={() => console.log('delete')}>Delete</MenuItem>
        </MenuContent>
      </Menu>
    </Theme>
  );
}

Install

Import

import { Menu, MenuTrigger, MenuContent, MenuItem, MenuCheckItem, MenuRadioGroup, MenuRadioItem, MenuLabel, MenuSeparator } from '@/components/klarhimmel/ui/menu';
Lands in
components/klarhimmel/ui/menu.tsx, plus cn() and the theme item if they are missing. Import theme.css after Tailwind.
Render in
Render it inside <Theme>, once near the root of your app. Tokens and fonts are scoped to it, so outside it the component is unstyled.
Limit
Overlays render into the Theme root with position: fixed. A transform on an ancestor re-parents fixed (a dialog centres in the wrapper) and overflow: hidden clips menus. Render <Theme> at the app root, outside any transformed wrapper.

Examples

  • Options

    Showing: Guest, Room, sorted by guest.

    import { useState } from 'react';
    import { ChevronDown } from 'lucide-react';
    import { Button } from '@/components/klarhimmel/ui/button';
    import { Menu, MenuCheckItem, MenuContent, MenuLabel, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger } from '@/components/klarhimmel/ui/menu';
    
    const columns = ['Guest', 'Room', 'Arrival', 'Price'];
    
    export default function MenuOptions() {
      const [sort, setSort] = useState('guest');
      const [shown, setShown] = useState<string[]>(['Guest', 'Room']);
      return (
        <div className="flex min-h-12 flex-col items-start gap-3">
          <Menu>
            <MenuTrigger asChild>
              <Button variant="secondary" iconEnd={<ChevronDown aria-hidden className="size-4" />}>Columns</Button>
            </MenuTrigger>
            <MenuContent>
              <MenuLabel>Show columns</MenuLabel>
              <MenuSeparator />
              {columns.map((c) => (
                <MenuCheckItem key={c} checked={shown.includes(c)} onCheckedChange={(on) => setShown((s) => (on ? [...s, c] : s.filter((x) => x !== c)))}>
                  {c}
                </MenuCheckItem>
              ))}
              <MenuSeparator />
              <MenuLabel>Sort by</MenuLabel>
              <MenuRadioGroup value={sort} onValueChange={setSort}>
                <MenuRadioItem value="guest">Guest</MenuRadioItem>
                <MenuRadioItem value="arrival">Arrival</MenuRadioItem>
              </MenuRadioGroup>
            </MenuContent>
          </Menu>
          <p aria-live="polite" className="text-kh-small text-kh-muted">Showing: {shown.join(', ') || 'nothing'}, sorted by {sort}.</p>
        </div>
      );
    }
  • Row actions

    • Seaside suite
    • Garden room
    • Attic double
    import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
    import { IconButton } from '@/components/klarhimmel/ui/icon-button';
    import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from '@/components/klarhimmel/ui/menu';
    
    const rooms = ['Seaside suite', 'Garden room', 'Attic double'];
    
    export default function MenuRowActions() {
      return (
        <ul className="flex w-full max-w-md flex-col divide-y divide-kh-line rounded-kh border border-kh-line bg-kh-surface">
          {rooms.map((room) => (
            <li key={room} className="flex items-center justify-between gap-3 py-1 pl-4 pr-2 text-kh-small">
              {room}
              <Menu>
                <MenuTrigger asChild>
                  <IconButton label={`Actions for ${room}`} noTooltip size="sm"><MoreHorizontal aria-hidden className="size-5" /></IconButton>
                </MenuTrigger>
                <MenuContent>
                  <MenuItem icon={<Pencil />}>Rename</MenuItem>
                  <MenuSeparator />
                  <MenuItem icon={<Trash2 />} danger>Delete</MenuItem>
                </MenuContent>
              </Menu>
            </li>
          ))}
        </ul>
      );
    }

Anatomy

The parts and how they nest.

Menu
├─ MenuTrigger (asChild)
└─ MenuContent
   ├─ MenuLabel
   ├─ MenuItem / MenuCheckItem
   └─ MenuSeparator
  • MenuHolds the open state.
  • MenuTriggerWrap your Button with asChild.
  • MenuContentThe floating list. Portals into the theme root so it keeps the tokens.
  • MenuItemAn action. Takes icon, shortcut, danger and onSelect.
  • MenuCheckItemA toggle that stays in the menu.
  • MenuLabel, MenuSeparatorA group heading and a divider.

API reference

PropTypeDefaultDescription
MenuTrigger asChildReactNode-Wrap the Button that opens the menu.
MenuContent align / side'start' | 'center' | 'end' / 'top' | 'right' | 'bottom' | 'left''start' / 'bottom'Where it opens; it flips to stay on screen.
MenuItem onSelect() => void-Runs on Enter, Space or click, then the menu closes.
MenuItem icon / shortcutReactNode / string-A leading icon and a trailing shortcut hint.
MenuItem danger / disabledbooleanfalseDestructive tint; disabled items are skipped by the arrow keys.
MenuCheckItem checked / onCheckedChangeboolean / (checked: boolean) => void-An item that toggles. The menu stays open, so several can be changed in one visit.
MenuRadioGroup, MenuRadioItemstring / (value: string) => void-value and onValueChange on the group, value on each item. A choice of one; picking an item keeps the menu open.
MenuLabel, MenuSeparator--A group heading and a divider.

Accessibility

KeyDoes
Enter / Space / DownOpens the menu on the trigger
Up / DownMoves between items
Home / EndFirst and last item
A letterJumps to the item that starts with it
Enter / SpaceChooses the item and closes
TabCloses the menu
EscapeCloses and returns focus to the trigger

Disabled items are skipped by the arrow keys. Tab closes the menu and returns focus to the trigger. Check and radio items keep it open.

Accessibility