@k2b/ui
Menus
Dropdown and context menus with keyboard and viewport behavior.
Top-layer menus with keyboard navigation, focus restoration, viewport clamping, and light dismiss. Icon-only triggers default to the quiet ghost variant; `width` is a CSS length and defaults to 12rem.
TSX
Copy<Dropdown.Root items={[{ label: "Duplicate", action: duplicate }]}> <Dropdown.Trigger variant="secondary">Actions</Dropdown.Trigger></Dropdown.Root> <Dropdown.Root items={items}> <Dropdown.Trigger iconOnly label="More actions"> <i class="ti ti-dots" aria-hidden="true" /> </Dropdown.Trigger></Dropdown.Root> {/* width is a CSS length */}<Dropdown.Root items={items} width="18rem" position="bottom-left"> <Dropdown.Trigger variant="secondary">Wide menu</Dropdown.Trigger></Dropdown.Root> <ContextMenu label="Record actions" items={items}> <div>Right click or press Shift+F10</div></ContextMenu>Dropdown opens actions from a visible trigger. ContextMenu opens the same item model at the pointer position on right-click or beside its trigger from the keyboard. The parent supplies actions, links, and state changes.
Use menus
Use Dropdown for secondary actions that need an explicit, keyboard-accessible trigger.
Use ContextMenu as a pointer shortcut on a record or canvas. Do not make it the only way to reach an action.
Do not use either menu as a select input. Choose the control from the state the user is editing:
| Task | Component |
|---|---|
| Run an action or open a link | Dropdown |
| Choose one form value | Select |
| Choose one compact toolbar value | SelectChip |
| Choose several values | MultiSelectInput |
| Filter an existing result set | FilterChip |
See Select inputs for the controlled selection contracts.
Use an item's optional trailing JSX for quiet information such as a view
count. The link label stays the view name.
Import
import {
ContextMenu,
Dropdown,
type DropdownItem,
IconButton,
} from "@k2b/ui";Touch menus
On touch devices, every menu item in Dropdown, ContextMenu, SplitButton,
SelectChip, and FilterChip is at least 44 px (2.75rem) tall, also without
variant="touch". Items sit close together, so the item itself grows instead
of reaching into its neighbour with an invisible tap area. This follows the
device, not the input in use: a laptop with a touch screen gets the taller
items with a mouse too, and devices without a touch screen keep the compact
32 px items.
Set variant="touch" on Dropdown.Root for larger touch targets on every
device. It uses rows at least 52 px high, 16 px labels, larger icons, and an
18rem minimum width.
An explicit width still takes precedence; the menu stays within the viewport
and scrolls when necessary. Keyboard navigation and selection behave the same.
Omitting variant (or using variant="default") keeps the compact appearance
and 12rem minimum width. Trigger styling is configured separately.
<Dropdown.Root items={items} variant="touch" align="end">
<Dropdown.Trigger iconOnly label="Menu">
<i class="ti ti-dots" aria-hidden="true" />
</Dropdown.Trigger>
</Dropdown.Root>Set phone="sheet" on Dropdown.Root to open the same items in a bottom sheet
below 48rem. The default phone="menu" keeps the menu beside its trigger.
A controlled dropdown (open supplied) always stays a menu. Choices with
closeOnSelect: false keep the sheet open, with reactive checked state, just
as they do in the desktop menu. The inherited UI locale follows into the sheet.
Like the menu, the sheet closes when the dropdown becomes disabled or unmounts.
Other actions run after the sheet has left the history, so they may navigate
or open a dialog. Set immediate: true on an action that needs the tap itself,
such as a clipboard write or a download; it runs during the tap and must not
navigate or open a dialog.
Items and sections
Both components accept DropdownItem[]. An item is one of:
- an action with
label, optionalicon, andaction; - a link with
label,href, and optionalexternal; with anactionas well, a plain click runs the action instead of following the link, while modified clicks, a middle click, and the link's context menu keep the link, for example for an in-app panel that also has its own address; - a radio or checkbox choice with
choice,checked, andaction; - a section with optional
sectionLabeland nested actions or choices.
A choice can also take href: a plain click, Enter, or Space runs its action, while modified clicks and middle-click can open the link in a new tab.
A section with layout: "row" sets a few short radio choices side by side as
one segmented row, such as a sort direction or a color mode. Give it a
sectionLabel: the label names the choice above the row and for screen
readers, and an unlabeled row stays an unnamed group. It uses the
SegmentedControl look: an inset track with the selected choice raised, labels
only, so icons and check marks are left out. Keep it to labels that fit the
menu's width; longer choices belong in an ordinary section. Up and Down move
through the row like through any items, and Left and Right cycle within it.
Set variant: "danger" only on destructive items.
Menus deliberately do not accept arbitrary interactive content. Use a dialog
for a form or richer workflow. Use Select, MultiSelectInput, SelectChip,
or FilterChip when the interaction edits a value or filter.
Dropdown.Root accepts items, open, onOpenChange, position, align,
width, class, menuClass, label, disabled, phone, and onClose.
Dropdown.Trigger owns the native button and its SSR-visible menu semantics.
Use its normal button appearance by default. Set appearance="plain" only
when a specialized component class owns the complete visual treatment.
An iconOnly trigger defaults to the quiet ghost variant and shows its
label as a tooltip, matching IconButton; pass another variant explicitly
when the icon action should be emphasized, and tooltip to change or omit the
hint.
Menu width
Menus size to their entries, so a caller does not need to know a width. A menu
is as wide as its longest label or description, at least 12rem (18rem for
variant="touch", 13rem for FilterChip, and 10rem for SelectChip), and
at most the viewport width less 1rem per side. An entry or section label
longer than that wraps instead of ending in an ellipsis, and an unbroken word
breaks, so every label stays readable on a phone. A radio choice reserves its
check mark while unchecked, so choosing an option never widens an open menu.
ContextMenu follows the same rule with a 13rem minimum and 0.5rem to each
viewport edge, and is measured before it is clamped beside the pointer. When
entries change while a menu is open, it keeps its alignment with the trigger or
pointer and stays inside the viewport. Opening a menu never moves the trigger
or the page around it.
width is an optional exact override as a CSS length string, not a class
name. It sets the menu's --k2b-dropdown-width; entries wrap within it, and
the viewport bound still applies. Use it only when a menu must line up with
something else:
<Dropdown.Root items={actions} width="18rem" position="bottom-left">
<Dropdown.Trigger variant="secondary">Actions</Dropdown.Trigger>
</Dropdown.Root>ContextMenu wraps children and uses the same items model. It also accepts
disabled, onOpen, onClose, an optional stable id, and an accessible
label.
API reference
type DropdownActionBase = {
class?: string; disabled?: boolean; icon?: string; image?: string; label: string; ariaLabel?: string; description?: string; trailing?: JSX.Element;
variant?: "danger";
};
type DropdownAction =
| (DropdownActionBase & {
action: () => void;
closeOnSelect?: boolean;
immediate?: boolean;
href?: never;
external?: never;
})
| (DropdownActionBase & {
href: string;
external?: boolean;
action?: () => void;
})
| (DropdownActionBase & {
disabled: true;
action?: never;
href?: never;
external?: never;
});
type DropdownChoice = DropdownActionBase & {
action: () => void; checked: boolean | (() => boolean); choice: "checkbox" | "radio";
href?: string; closeOnSelect?: boolean; color?: string;
};
type DropdownSection = {
sectionLabel?: string; layout?: "row"; items: readonly (DropdownAction | DropdownChoice)[];
};
type DropdownItem = DropdownAction | DropdownChoice | DropdownSection;
type DropdownPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left" | "right-start";
type DropdownProps = {
items: readonly DropdownItem[]; children: JSX.Element;
position?: DropdownPosition | (() => DropdownPosition); variant?: "default" | "touch"; width?: string;
class?: string; menuClass?: string; onClose?: () => void; open?: boolean;
onOpenChange?: (open: boolean) => void; disabled?: boolean; label?: string; align?: "start" | "end"; phone?: "menu" | "sheet";
};
type ContextMenuProps = {
items: readonly DropdownItem[]; children: JSX.Element; class?: string | ((isOpen: boolean) => string);
tabIndex?: number; disabled?: boolean; onClose?: () => void; onOpen?: () => void; id?: string;
label?: string;
};DropdownActionBase names the shared shape here; import the public DropdownAction, DropdownChoice, DropdownSection and DropdownItem types. Action callbacks return void; asynchronous work and its errors stay with the host. Actions and choices close on activation unless closeOnSelect: false. A section contains actions/choices, not nested sections.
Use closeOnSelect: false for an action that changes a value in place, such as a cycling setting. Keep the item object stable and expose changing label, icon, and ariaLabel fields as getters: the row updates in place and keeps focus. ariaLabel must contain the visible label.
position defaults to "bottom-right"; align ("start" | "end") is an optional alignment override. variant="default"; width is an optional exact CSS length, and without it the menu sizes to its entries. Pair controlled open with onOpenChange, or omit both for internal state. When a key on the trigger opens a controlled menu and onOpenChange sets open right away, the focus moves to the first or last item, as in an uncontrolled menu; setting open from elsewhere leaves the focus where it is. ContextMenu has no controlled open prop. DropdownItem is also a low-level JSX export; normal consumers use the declarative items API above.
Accessibility
Dropdown.Trigger renders aria-haspopup, aria-expanded, and
aria-controls in server HTML. Enter, Space, and arrow keys open it. Arrow
keys, Home, End, Escape, and Tab manage the open menu.
Unless closeOnSelect: false, declarative actions close the visible menu
before their callback runs. A state update in the callback therefore cannot
briefly repaint stale menu content.
Interactive menus close synchronously; @k2b/ui does not inherit a host's
generic [popover] exit transition. Animate decorative surfaces such as
tooltips separately instead of delaying an action menu's dismissal.
ContextMenu opens from the pointer context-menu event, the Context Menu key, or Shift+F10. From the keyboard it opens beside the focused element inside it, such as a row of a tree, and otherwise beside the wrapper. Pair it with a visible Dropdown when the same actions must remain discoverable without knowing the shortcut. For touch, GestureMenu adds swipe, double-tap, and long-press shortcuts to the same item model and opens the menu as a bottom sheet on phones and at the finger from 48rem.
Labels must describe the action without relying on their icons. External links open in a new tab with the appropriate relationship attributes.
Runtime
Both menus require browser code. Dropdown uses the native Popover API, measures the trigger and menu, and clamps every supported position to an eight-pixel viewport inset. It repositions on viewport resize and ancestor scrolling.
ContextMenu renders its menu through a Solid portal, clamps pointer and keyboard openings to the viewport, and closes on outside pointer input, a change of the viewport's size, or scrolling outside the menu. A resize event that leaves the viewport's size unchanged keeps it open. Closing with Escape restores focus to the element inside it that had focus as the menu opened, such as a row or a day of a composite widget, and otherwise to the wrapper.
Example
const actions: DropdownItem[] = [
{
label: "Edit",
icon: "ti ti-pencil",
action: openEditor,
},
{
label: "Delete",
icon: "ti ti-trash",
variant: "danger",
action: confirmDelete,
},
];
<Dropdown.Root items={actions}>
<Dropdown.Trigger iconOnly label="Actions">
<i class="ti ti-dots" aria-hidden="true" />
</Dropdown.Trigger>
</Dropdown.Root>;