PlMenu
A list of actions that appears when something is pressed. Roving focus, typeahead, submenus with a safe triangle, and the roles that make any of it mean something to a screen reader.
import { PlButton, PlMenu, PlMenuItem, PlMenuSeparator } from 'plass-ui';
<PlMenu trigger={<PlButton variant="glass">Actions</PlButton>}>
<PlMenuItem shortcut="⌘X">Cut</PlMenuItem>
<PlMenuItem shortcut="⌘C">Copy</PlMenuItem>
<PlMenuSeparator />
<PlMenuItem color="danger">Delete</PlMenuItem>
</PlMenu>;import 'package:plass_ui/plass_ui.dart';
PlMenu(
items: <PlMenuEntry>[
PlMenuItem(label: 'Cut', shortcut: '⌘X', onPressed: cut),
PlMenuItem(label: 'Copy', shortcut: '⌘C', onPressed: copy),
const PlMenuSeparator(),
PlMenuItem(label: 'Delete', color: PlassColor.danger, onPressed: remove),
],
trigger: (BuildContext context, VoidCallback open, bool isOpen) =>
PlButton(onPressed: open, variant: PlassVariant.glass, child: const Text('Actions')),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactElement | — | The element that opens the menu. Optional — a controlled menu opened from elsewhere needs none |
| open · defaultOpen · onOpenChange | boolean · boolean · (open: boolean) => void | — | The open state, controlled or uncontrolled |
| side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Which edge of the trigger it hangs off |
| align | 'start' | 'center' | 'end' | 'start' | Where it sits along that edge |
| sideOffset | number | 6 | Distance from the trigger, in pixels |
| modal | boolean | true | Whether the page behind is taken away while the menu is open |
| openOnHover | boolean | false | Opens on hover as well as on click. For a menu bar |
| loopFocus | boolean | true | Whether the arrow keys wrap from the last row back to the first |
| disabled | boolean | false | The trigger stops opening anything |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The popup's radius, type scale and row padding |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. A row can override it |
| densityshared | 'default' | 'compact' | 'default' | Changes a row's padding and nothing else |
| children | ReactNode | — | The rows |
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | List<PlMenuEntry> | — | The rows, as a list of descriptions rather than children — the menu owns the highlight and the typeahead itself |
| trigger * | Widget Function(BuildContext, VoidCallback open, bool isOpen) | — | What opens the menu. A builder rather than a widget, because a trigger almost always wants to know whether it is open |
| sizeshared | PlassSize | PlassSize.md | The popup's radius, type scale and row padding |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. A row can override it |
| densityshared | PlassDensity | PlassDensity.standard | Changes a row's padding and nothing else |
| side | PlassSide | PlassSide.bottom | Which edge of the trigger it hangs off |
| align | PlassAlign | PlassAlign.start | Where it sits along that edge |
| sideOffset | double | 6 | Distance from the trigger, in pixels |
| loopFocus | bool | true | Whether the arrow keys wrap from the last row back to the first |
| disabled | bool | false | The trigger stops opening anything |
| onOpenChange | ValueChanged<bool>? | — | Told whenever the menu opens or closes |
| label | String? | — | The name a screen reader gives the popup |
PlMenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
| onClick | (event: MouseEvent) => void | — | What the row does. Not given, and not a link, the row is a label |
| href · target | string | — | Renders the row as a real <a>. A menu of links has to be links |
| startIcon · endIcon | ReactNode | — | The slots before and after the label |
| shortcut | ReactNode | — | The keystroke that does the same thing, muted at the end of the row. Text only — the application binds it |
| description | ReactNode | — | A second line under the label, one step down and muted |
| color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | — | Re-points the row's colour family — danger for the one that deletes |
| closeOnClick | boolean | true | Whether picking the row closes the menu |
| disabled | boolean | false | Unavailable. Still listed, and still found by typeahead |
| label | string | — | What typeahead matches against, when the label is not a plain string |
| children | ReactNode | — | The label |
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | String | — | The label. A String rather than a widget: it is what is drawn, what is announced, and what typeahead matches against |
| onPressed | VoidCallback? | — | What the row does. Not given, and not a link, the row is a label |
| startIcon · endIcon | Widget? | — | The slots before and after the label |
| shortcut | String? | — | The keystroke that does the same thing, muted at the end of the row. Text only — the application binds it |
| description | String? | — | A second line under the label, one step down and muted |
| color | PlassColor? | — | Re-points the row's colour family — danger for the one that deletes |
| closeOnPress | bool | true | Whether picking the row closes the menu |
| disabled | bool | false | Unavailable. Still listed, and still found by typeahead |
PlMenuCheckboxItem and PlMenuRadioItem
| Prop | Type | Default | Description |
|---|---|---|---|
| checked · defaultChecked · onCheckedChange | boolean · boolean · (checked: boolean) => void | — | The checked state |
| value * | string | number | — | PlMenuRadioItem only: what this row sets the group to |
| closeOnClick | boolean | false | Whether ticking closes the menu. false here, against a plain row |
| endIcon · shortcut · description · color · disabled · label | — | — | As on PlMenuItem |
| Prop | Type | Default | Description |
|---|---|---|---|
| checked · onChanged | bool · ValueChanged<bool>? | — | The checked state. Controlled: there is no defaultChecked |
| selected · onPressed | bool · VoidCallback? | — | PlMenuRadioItem only: whether this is the chosen one, and what choosing it does. The row is told, rather than a group holding a value |
| closeOnPress | bool | false | Whether ticking closes the menu. false here, against a plain row |
| label · endIcon · shortcut · description · color · disabled | — | — | As on PlMenuItem |
PlMenuSubmenu
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | The label on the row that opens it |
| startIcon | ReactNode | — | The slot before the label |
| side | 'top' | 'right' | 'bottom' | 'left' | 'right' | Which edge of the parent row it opens against |
| sideOffset | number | 4 | Distance from the parent menu, in pixels |
| disabled | boolean | false | The row stops opening anything |
| children | ReactNode | — | The nested rows |
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | String | — | The label on the row that opens it |
| items * | List<PlMenuEntry> | — | The nested rows |
| startIcon | Widget? | — | The slot before the label |
| disabled | bool | false | The row stops opening anything |
PlContextMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| content * | ReactNode | — | The rows, exactly as they are written inside a PlMenu |
| children * | ReactNode | — | The area that answers a right-click or a long press |
| open · defaultOpen · onOpenChange | boolean · boolean · (open: boolean) => void | — | The open state |
| loopFocus | boolean | true | Whether the arrow keys wrap from the last row back to the first |
| disabled | boolean | false | The area stops opening anything |
| size · color · densityshared | — | — | As on PlMenu |
PlContextMenu is not in the Flutter package yet.
There is no variant, for the reason PlModal has none: the three materials answer "how much does this surface assert itself against the page", and a popup that has taken the pointer has already answered it. There is no elevation either, a menu genuinely floats, which is the one case the ladder exists for, so it is fixed at its top rung. What the shared axes mean across the library is in prop conventions.
Examples
The items array
Composed, not passed as data, the opposite of PlSelect, and deliberately.
A select's options are values out of a list a caller already has, so they are data. A menu's rows are code, each one a different handler, a different icon, sometimes a link, sometimes a submenu. Passing them as data would mean an items type with a variant for every shape a row can take, which is a component tree spelled as a discriminated union.
Descriptions, not composed widgets, and this is the one place the two packages disagree about a component's shape.
It is forced. React composes because Base UI reads the DOM the rows are written into: it finds them, counts them, moves a roving highlight through them and matches typeahead against them without anybody handing it a list. There is no tree to walk here, so the menu has to be told, the same reason PlAccordion, PlTabs and PlSelect all take descriptions.
PlMenuEntry is a sealed hierarchy rather than one class with a discriminator on it, which is what lets a row be a different kind of thing and the switch over it be checked.
import { PlButton, PlMenu, PlMenuItem, PlMenuSeparator } from 'plass-ui';
const Star = () => (
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="m8 1.6 1.86 3.9 4.14.56-3.02 2.9.76 4.24L8 11.16 4.26 13.2l.76-4.24L2 6.06l4.14-.56z" />
</svg>
);
export default function MenuRows() {
return (
<PlMenu trigger={<PlButton variant="glass">Rows</PlButton>}>
<PlMenuItem startIcon={<Star />} shortcut="⌘D">
With an icon
</PlMenuItem>
<PlMenuItem description="A second line, one step down and muted">
With a description
</PlMenuItem>
<PlMenuItem href="https://plass.cdget.com" target="_blank">
A real link
</PlMenuItem>
<PlMenuItem disabled>Unavailable</PlMenuItem>
<PlMenuSeparator />
<PlMenuItem color="danger">Delete everything</PlMenuItem>
</PlMenu>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/glyphs.dart';
class MenuRows extends StatelessWidget {
const MenuRows({super.key});
@override
Widget build(BuildContext context) {
return PlMenu(
items: const <PlMenuEntry>[
PlMenuItem(label: 'With an icon', startIcon: StarGlyph(), shortcut: '⌘D'),
PlMenuItem(
label: 'With a description',
description: 'A second line, one step down and muted',
),
PlMenuItem(label: 'Unavailable', disabled: true),
PlMenuSeparator(),
PlMenuItem(label: 'Delete everything', color: PlassColor.danger),
],
trigger: (BuildContext context, VoidCallback open, bool isOpen) =>
PlButton(onPressed: open, variant: PlassVariant.glass, child: const Text('Rows')),
);
}
}color on a row
A row can name its own family, danger on the one that deletes, and the slots are re-declared on the row so the tint, the hairline and the text all turn over together rather than one of them staying indigo.
It is a branch rather than a class appended next to the default. Two Tailwind utilities of equal specificity on one element resolve by their order in the generated stylesheet rather than by the order they were written in, so an appended accent would silently do nothing on some builds and work on others.
Groups and separators
A group's label is a heading, not a row: it cannot be picked, it is not in the typeahead, and Base UI wires it to the rows underneath it.
import { PlButton, PlMenu, PlMenuGroup, PlMenuItem, PlMenuSeparator } from 'plass-ui';
export default function MenuGroups() {
return (
<PlMenu trigger={<PlButton variant="glass">Grouped</PlButton>}>
<PlMenuGroup label="Edit">
<PlMenuItem shortcut="⌘X">Cut</PlMenuItem>
<PlMenuItem shortcut="⌘C">Copy</PlMenuItem>
</PlMenuGroup>
<PlMenuSeparator />
<PlMenuGroup label="Document">
<PlMenuItem shortcut="⌘S">Save</PlMenuItem>
<PlMenuItem shortcut="⌘P">Print</PlMenuItem>
</PlMenuGroup>
</PlMenu>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class MenuGroups extends StatelessWidget {
const MenuGroups({super.key});
@override
Widget build(BuildContext context) {
return PlMenu(
items: const <PlMenuEntry>[
PlMenuGroup(
label: 'Edit',
items: <PlMenuEntry>[
PlMenuItem(label: 'Cut', shortcut: '⌘X'),
PlMenuItem(label: 'Copy', shortcut: '⌘C'),
],
),
PlMenuSeparator(),
PlMenuGroup(
label: 'Document',
items: <PlMenuEntry>[
PlMenuItem(label: 'Save', shortcut: '⌘S'),
PlMenuItem(label: 'Print', shortcut: '⌘P'),
],
),
],
trigger: (BuildContext context, VoidCallback open, bool isOpen) =>
PlButton(onPressed: open, variant: PlassVariant.glass, child: const Text('Grouped')),
);
}
}Ticking and choosing
A checkbox row is marked with a tick; a radio row with a dot. That is the same distinction PlCheckbox and PlRadioGroup make everywhere else. A tick says "and", a dot says "instead of".
Both default to staying open when they are picked, against the true a plain row takes. A list of things to tick is a list you tick more than one of.
There is no radio group. Every input in this package is controlled, so a PlMenuRadioItem is told whether it is the chosen one and reports that it was pressed, a group that owned the value would be the one thing in the library that did not report and forget.
import { useState } from 'react';
import {
PlButton,
PlMenu,
PlMenuCheckboxItem,
PlMenuGroup,
PlMenuRadioGroup,
PlMenuRadioItem,
PlMenuSeparator
} from 'plass-ui';
export default function MenuSelection() {
const [wrap, setWrap] = useState(true);
const [minimap, setMinimap] = useState(false);
const [layout, setLayout] = useState<string | number>('list');
return (
<PlMenu trigger={<PlButton variant="glass">View</PlButton>}>
<PlMenuGroup label="Show">
<PlMenuCheckboxItem checked={wrap} onCheckedChange={setWrap} shortcut="⌥Z">
Word wrap
</PlMenuCheckboxItem>
<PlMenuCheckboxItem checked={minimap} onCheckedChange={setMinimap}>
Minimap
</PlMenuCheckboxItem>
</PlMenuGroup>
<PlMenuSeparator />
<PlMenuGroup label="Layout">
<PlMenuRadioGroup value={layout} onValueChange={setLayout}>
<PlMenuRadioItem value="list">List</PlMenuRadioItem>
<PlMenuRadioItem value="grid">Grid</PlMenuRadioItem>
<PlMenuRadioItem value="columns">Columns</PlMenuRadioItem>
</PlMenuRadioGroup>
</PlMenuGroup>
</PlMenu>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class MenuSelection extends StatefulWidget {
const MenuSelection({super.key});
@override
State<MenuSelection> createState() => _MenuSelectionState();
}
class _MenuSelectionState extends State<MenuSelection> {
bool _wrap = true;
bool _minimap = false;
String _layout = 'list';
@override
Widget build(BuildContext context) {
return PlMenu(
items: <PlMenuEntry>[
PlMenuGroup(
label: 'Show',
items: <PlMenuEntry>[
PlMenuCheckboxItem(
label: 'Word wrap',
shortcut: '⌥Z',
checked: _wrap,
onChanged: (bool next) => setState(() => _wrap = next),
),
PlMenuCheckboxItem(
label: 'Minimap',
checked: _minimap,
onChanged: (bool next) => setState(() => _minimap = next),
),
],
),
const PlMenuSeparator(),
PlMenuGroup(
label: 'Layout',
items: <PlMenuEntry>[
for (final String layout in <String>['list', 'grid', 'columns'])
PlMenuRadioItem(
label: layout,
selected: _layout == layout,
onPressed: () => setState(() => _layout = layout),
),
],
),
],
trigger: (BuildContext context, VoidCallback open, bool isOpen) =>
PlButton(onPressed: open, variant: PlassVariant.glass, child: const Text('View')),
);
}
}Submenus
The row that opens one is the same row every other item is, wearing a chevron. It opens on hover, on Enter and on the arrow key that points at it, and a diagonal reach toward it does not close it, Base UI tracks a safe triangle from the pointer to the popup.
Nesting is unlimited: a submenu renders its children inside a popup that is itself a menu, so a submenu of a submenu needs no different component.
import { PlButton, PlMenu, PlMenuItem, PlMenuSeparator, PlMenuSubmenu } from 'plass-ui';
export default function MenuSubmenus() {
return (
<PlMenu trigger={<PlButton variant="glass">Share</PlButton>}>
<PlMenuItem>Copy link</PlMenuItem>
<PlMenuSeparator />
<PlMenuSubmenu label="Send to">
<PlMenuItem>Email</PlMenuItem>
<PlMenuItem>Message</PlMenuItem>
<PlMenuSubmenu label="More">
<PlMenuItem>Print</PlMenuItem>
<PlMenuItem>Fax, apparently</PlMenuItem>
</PlMenuSubmenu>
</PlMenuSubmenu>
<PlMenuSubmenu label="Export as">
<PlMenuItem>PDF</PlMenuItem>
<PlMenuItem>Markdown</PlMenuItem>
</PlMenuSubmenu>
</PlMenu>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class MenuSubmenus extends StatelessWidget {
const MenuSubmenus({super.key});
@override
Widget build(BuildContext context) {
return PlMenu(
items: const <PlMenuEntry>[
PlMenuItem(label: 'Copy link'),
PlMenuSeparator(),
PlMenuSubmenu(
label: 'Send to',
items: <PlMenuEntry>[
PlMenuItem(label: 'Email'),
PlMenuItem(label: 'Message'),
PlMenuSubmenu(
label: 'More',
items: <PlMenuEntry>[
PlMenuItem(label: 'Print'),
PlMenuItem(label: 'Fax, apparently'),
],
),
],
),
PlMenuSubmenu(
label: 'Export as',
items: <PlMenuEntry>[
PlMenuItem(label: 'PDF'),
PlMenuItem(label: 'Markdown'),
],
),
],
trigger: (BuildContext context, VoidCallback open, bool isOpen) =>
PlButton(onPressed: open, variant: PlassVariant.glass, child: const Text('Share')),
);
}
}size and density
size sets the popup's radius, its type scale and the row padding ladder; density touches the padding and nothing else.
A row has a padding track of its own rather than the sheet one. A PlList row spans a sheet something else decided the width of; a menu row is inside a popup exactly as wide as its longest label, and the sheet track's px-5 would add 40px to a menu that says "Cut", which is how a five-row menu ends up the width of a dialog.
import { PlButton, PlMenu, PlMenuItem } from 'plass-ui';
export default function MenuSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<PlMenu
key={size}
size={size}
trigger={
<PlButton size={size} variant="glass">
{size}
</PlButton>
}
>
<PlMenuItem shortcut="⌘X">Cut</PlMenuItem>
<PlMenuItem shortcut="⌘C">Copy</PlMenuItem>
</PlMenu>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class MenuSizes extends StatelessWidget {
const MenuSizes({super.key});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final PlassSize size in <PlassSize>[PlassSize.sm, PlassSize.md, PlassSize.lg])
PlMenu(
size: size,
items: const <PlMenuEntry>[
PlMenuItem(label: 'Cut', shortcut: '⌘X'),
PlMenuItem(label: 'Copy', shortcut: '⌘C'),
],
trigger: (BuildContext context, VoidCallback open, bool isOpen) => PlButton(
size: size,
onPressed: open,
variant: PlassVariant.glass,
child: Text(size.name),
),
),
],
);
}
}PlContextMenu
The same menu, opened by a right-click or a long press instead of by a button.
It takes the rows as content and the area as children, which is PlTooltip's shape rather than PlMenu's, because here the trigger is a region of the page rather than one element you hand over, and the region is the thing being wrapped. The popup is positioned at the pointer rather than against an anchor, and the long press is what makes it reachable on a touch screen at all.
import { PlContextMenu, PlMenuItem, PlMenuSeparator } from 'plass-ui';
export default function MenuContextDemo() {
return (
<PlContextMenu
content={
<>
<PlMenuItem shortcut="⌘R">Rename</PlMenuItem>
<PlMenuItem shortcut="⌘D">Duplicate</PlMenuItem>
<PlMenuSeparator />
<PlMenuItem color="danger">Delete</PlMenuItem>
</>
}
>
<div className="flex h-28 w-full max-w-sm items-center justify-center rounded-(--plass-radius-lg) border border-dashed border-(--plass-border) text-sm text-(--plass-muted-fg)">
Right-click, or press and hold
</div>
</PlContextMenu>
);
}Accessibility
- Built on Base UI's Menu, which owns everything that makes a menu a menu rather than a floating list of
<div>s: themenuandmenuitemroles, roving focus with the arrow keys, Home and End, typeahead, Esc, closing on an outside click, and restoring focus to the trigger. - A row with an
hrefis a real<a>. A menu of links that are not links cannot be opened in a new tab, cannot be copied, and tells a screen reader the wrong thing about every one of them. - Rows carry no focus ring. Base UI moves focus onto the highlighted row itself, so a ring would draw a rectangle inside the popup on every arrow press; the tint is the focus indicator, which is what makes it the same one the mouse gets.
data-highlightedrather than:hoveris what lights a row, so the keyboard and the pointer light the same one.- A disabled row stays listed and stays findable by typeahead. A row that vanishes when it is unavailable is a menu that changes length.
- The popup animates its opacity only. A menu that slides in has moved the row you were already reaching for, which is the one thing a menu must never do.
- Focus stays on the trigger while the popup is up, which is what
PlSelectdoes and for the same reason: the rows are painted in an overlay, and a focus scope lifted with them would take the keyboard away from the widget that knows what to do with it. The arrows,Home,End,Esc,Enterand typeahead are all bound there. - The pointer moves the same highlight the arrow keys do, so the mouse and the keyboard light one row rather than two, and moving onto a row of an outer menu is what closes the submenu open beside it.
- A row is a button node with its name and its action on it; a ticked row is marked checked and a chosen one selected in a mutually exclusive group. Everything drawn inside is excluded, so a glyph never becomes a second thing to read.
- The arrow that opens a submenu follows the writing direction, so it runs the other way under RTL.
- A disabled row stays listed and stays findable by typeahead. A row that vanishes when it is unavailable is a menu that changes length.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
| composed rows | items: List<PlMenuEntry> | Base UI reads the DOM the rows are written into. There is no tree to walk here, so the menu has to be told what it holds. |
children on a row | label, a String | It is drawn, announced and matched by typeahead. Only a string can be all three. |
PlMenuRadioGroup | PlMenuRadioItem.selected | Every input in this package is controlled; a group holding a value would be the one that is not. |
trigger, an element | trigger, a builder | It is handed the callback that opens the menu and whether it is open, which is what a trigger that stays lit needs. |
href on a row | — | There is no link element and nothing crawls a Flutter app. onPressed is where a router is called. |
modal | — | The popup is anchored rather than laid over the screen; the press that lands outside it closes it. |
PlContextMenu | — | There is no right-click gesture to build on that means the same thing on every platform this package runs on. A long press that opens a menu is onLongPress and a PlMenu the app opens itself. |
className, style | — | There is no class list and no style attribute to pass through. |