PlNavigationMenu
A site's navigation: a row of destinations, some of which open a panel of more of them. Every row is a real link, which is the whole reason this is not a menu.
import { PlNavigationMenu, PlNavigationMenuItem, PlNavigationMenuLink } from 'plass-ui';
<PlNavigationMenu>
<PlNavigationMenuItem label="Product" columns={2}>
<PlNavigationMenuLink href="/analytics" title="Analytics" description="Numbers over time" />
</PlNavigationMenuItem>
<PlNavigationMenuItem label="Pricing" href="/pricing" />
</PlNavigationMenu>;import 'package:plass_ui/plass_ui.dart';
PlNavigationMenu(
items: <PlNavigationMenuItem>[
PlNavigationMenuItem(
label: 'Product',
columns: 2,
links: <PlNavigationMenuLink>[
PlNavigationMenuLink(title: 'Analytics', onPressed: openAnalytics),
],
),
PlNavigationMenuItem(label: 'Pricing', onPressed: openPricing),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The row's height and type scale, and the panel's radius and padding with it |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. It reaches the hover, the open item and the focus rings; the sheet is never dyed |
| densityshared | 'default' | 'compact' | 'default' | Padding only |
| orientationshared | 'horizontal' | 'vertical' | 'horizontal' | Which way the row runs. vertical is a nav rail whose panels open beside it |
| value | string | null | — | Which item's panel is open, by its value. Nullish means closed |
| defaultValue | string | null | — | Which starts open, for an uncontrolled menu |
| onValueChange | (value: string | null) => void | — | Called when the open panel changes |
| delay | number | — | How long the pointer rests before a panel opens, in milliseconds |
| closeDelay | number | — | How long a panel stays after the pointer leaves, in milliseconds |
| sideOffset | number | 8 | Distance from the row, in pixels |
| children | ReactNode | — | The items |
| Prop | Type | Default | Description |
|---|---|---|---|
| sizeshared | PlassSize | PlassSize.md | The row's height and type scale, and the panel's radius and padding with it |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. It reaches the hover, the open item and the focus rings; the sheet is never dyed |
| densityshared | PlassDensity | PlassDensity.standard | Padding only |
| items * | List<PlNavigationMenuItem> | — | The items — data here rather than composed children, because the row has to know which item is which to open one panel at a time |
| initialValue | String? | — | Which item's panel starts open. There is no controlled mode: which panel is open is the pointer's and the keyboard's, not the app's data |
| onValueChanged | ValueChanged<String?>? | — | Called when the open panel changes |
| orientationshared | PlassOrientation | PlassOrientation.horizontal | Which way the row runs. vertical is a nav rail whose panels open beside it |
| delay | Duration | Duration(milliseconds: 50) | How long the pointer rests before a panel opens, in milliseconds |
| closeDelay | Duration | Duration(milliseconds: 100) | How long a panel stays after the pointer leaves, in milliseconds |
| sideOffset | double | 8 | Distance from the row, in pixels |
| semanticLabel | String? | — | The name the navigation region is announced by. Required when a screen has more than one |
Every native <nav> attribute passes straight through. color is excluded because it is a Plass prop here, and defaultValue / onChange because the menu spells them as a value and an onValueChange.
PlNavigationMenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | ReactNode | — | The word in the row |
| href | string | — | Makes the item a plain link rather than something that opens a panel. An item with an href and no children is a destination, and it is announced as one |
| target | string | — | Where the link opens. Anything other than this tab also gets noopener noreferrer merged into its rel |
| rel | string | — | The link's rel |
| startIcon | ReactNode | — | Content before the label |
| value | string | — | Identifies the item, for a controlled menu |
| disabled | boolean | false | Unavailable. The word stays in the row and opens nothing |
| columns | number | 1 | How many columns the panel lays its links out in |
| children | ReactNode | — | The panel's contents — usually PlNavigationMenuLinks |
| className | string | — | Classes on the word in the row, alongside the component's own rather than in place of them |
| style | CSSProperties | — | Inline styles on the word in the row, applied over the custom properties it wrote |
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | String | — | The word in the row |
| value | String? | — | Identifies the item in the menu's value. Left out, the label is used |
| onPressed | VoidCallback? | — | Makes the item a destination rather than something that opens a panel. There is no href here: where a destination is belongs to the app's own router |
| startIcon | Widget? | — | Content before the label |
| disabled | bool | false | Unavailable. The word stays in the row and opens nothing |
| columns | int | 1 | How many columns the panel lays its links out in |
| links | List<PlNavigationMenuLink> | const [] | The panel's contents — usually PlNavigationMenuLinks |
PlNavigationMenuLink
| Prop | Type | Default | Description |
|---|---|---|---|
| href * | string | — | Where it goes |
| title * | ReactNode | — | The row's name |
| description | ReactNode | — | A second line under it, one step down the scale and muted |
| startIcon | ReactNode | — | A glyph before the title |
| Prop | Type | Default | Description |
|---|---|---|---|
| title * | String | — | The row's name |
| description | String? | — | A second line under it, one step down the scale and muted |
| startIcon | Widget? | — | A glyph before the title |
| onPressed * | VoidCallback? | — | Where it goes. There is no navigator in this package, so this is where that is decided |
What the shared axes mean across the library is in prop conventions.
PlNavigationMenu or PlMenu
The difference is what the rows are.
A PlMenu holds actions. Its rows are menuitems, and the whole thing is a widget that traps the arrow keys and closes when one is chosen.
This holds links. It is a <nav> full of real <a>s, which is what puts them in the browser's link list, on the status bar under the pointer, in the middle-click menu and in a crawler's index. A destination that is a <div> with a click handler is in none of those.
Reach for a menu when the row does something. Reach for this when the row goes somewhere.
Examples
Items that link, and items that open
An item with an href and no children is a link. One with children is a trigger and a panel.
The difference is not cosmetic: the first is announced as a destination and the second as something that expands, so a screen reader tells a reader which of the two they are about to press.
import { PlNavigationMenu, PlNavigationMenuItem, PlNavigationMenuLink } from 'plass-ui';
export default function NavigationMenuStates() {
return (
<PlNavigationMenu>
<PlNavigationMenuItem label="A destination" href="#" />
<PlNavigationMenuItem label="A panel">
<PlNavigationMenuLink href="#" title="Somewhere" />
</PlNavigationMenuItem>
<PlNavigationMenuItem label="Unavailable" disabled>
<PlNavigationMenuLink href="#" title="Nowhere" />
</PlNavigationMenuItem>
<PlNavigationMenuItem label="Status page" href="https://example.com" target="_blank" />
</PlNavigationMenu>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class NavigationMenuStates extends StatelessWidget {
const NavigationMenuStates({super.key});
@override
Widget build(BuildContext context) {
return PlNavigationMenu(
items: <PlNavigationMenuItem>[
PlNavigationMenuItem(label: 'A destination', onPressed: () {}),
PlNavigationMenuItem(
label: 'A panel',
links: <PlNavigationMenuLink>[PlNavigationMenuLink(title: 'Somewhere', onPressed: () {})],
),
const PlNavigationMenuItem(
label: 'Unavailable',
disabled: true,
links: <PlNavigationMenuLink>[PlNavigationMenuLink(title: 'Nowhere')],
),
],
);
}
}columns
How many columns the panel lays its links out in. A PlNavigationMenuLink is one row: a title, an optional muted description under it and an optional glyph before it.
One panel is open at a time and it resizes between items rather than closing and reopening, which is what makes crossing the row read as one surface rather than three.
import { PlNavigationMenu, PlNavigationMenuItem, PlNavigationMenuLink } from 'plass-ui';
export default function NavigationMenuColumns() {
return (
<PlNavigationMenu>
<PlNavigationMenuItem label="One column">
<PlNavigationMenuLink href="#" title="Overview" />
<PlNavigationMenuLink href="#" title="Changelog" />
</PlNavigationMenuItem>
<PlNavigationMenuItem label="Three columns" columns={3}>
{['Analytics', 'Billing', 'Audit log', 'Integrations', 'Webhooks', 'Exports'].map(
(title) => (
<PlNavigationMenuLink key={title} href="#" title={title} />
)
)}
</PlNavigationMenuItem>
</PlNavigationMenu>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class NavigationMenuColumns extends StatelessWidget {
const NavigationMenuColumns({super.key});
@override
Widget build(BuildContext context) {
return PlNavigationMenu(
items: <PlNavigationMenuItem>[
PlNavigationMenuItem(
label: 'One column',
links: <PlNavigationMenuLink>[
PlNavigationMenuLink(title: 'Overview', onPressed: () {}),
PlNavigationMenuLink(title: 'Changelog', onPressed: () {}),
],
),
PlNavigationMenuItem(
label: 'Three columns',
columns: 3,
links: <PlNavigationMenuLink>[
for (final String title in <String>[
'Analytics',
'Billing',
'Audit log',
'Integrations',
'Webhooks',
'Exports',
])
PlNavigationMenuLink(title: title, onPressed: () {}),
],
),
],
);
}
}orientation
vertical is a nav rail whose panels open beside it rather than under it. The arrow keys follow either way.
import { PlNavigationMenu, PlNavigationMenuItem, PlNavigationMenuLink } from 'plass-ui';
export default function NavigationMenuOrientation() {
return (
<div className="w-48">
<PlNavigationMenu orientation="vertical" size="sm">
<PlNavigationMenuItem label="Overview" href="#" />
<PlNavigationMenuItem label="Reports">
<PlNavigationMenuLink href="#" title="Usage" />
<PlNavigationMenuLink href="#" title="Revenue" />
</PlNavigationMenuItem>
<PlNavigationMenuItem label="Settings" href="#" />
</PlNavigationMenu>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class NavigationMenuOrientation extends StatelessWidget {
const NavigationMenuOrientation({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 192,
child: PlNavigationMenu(
orientation: PlassOrientation.vertical,
size: PlassSize.sm,
items: <PlNavigationMenuItem>[
PlNavigationMenuItem(label: 'Overview', onPressed: () {}),
PlNavigationMenuItem(
label: 'Reports',
links: <PlNavigationMenuLink>[
PlNavigationMenuLink(title: 'Usage', onPressed: () {}),
PlNavigationMenuLink(title: 'Revenue', onPressed: () {}),
],
),
PlNavigationMenuItem(label: 'Settings', onPressed: () {}),
],
),
);
}
}The row's surface
At rest the items are the page's own words: no fill, no edge, no shadow. Five bordered boxes across the top of a site is a toolbar rather than a navigation, and a navigation should read as text until it is reached for.
The family arrives with the pointer and with the open panel, and the sheet itself is never dyed. The panel is the same frosted glass a PlMenu and a PlPopover draw.
A link that opens elsewhere
target on an item does what it does on an <a>, and anything other than this tab has noopener noreferrer merged into whatever rel was asked for.
Merged rather than replaced: the common reason to write a rel by hand is nofollow or sponsored, and spelling that as an override would silently take the protection off a link that still opens elsewhere.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
href on an item and on a link | onPressed | There is no navigator in this package and no address to resolve. Where a destination is belongs to the app's own router. |
composed PlNavigationMenuItem children | items: List<PlNavigationMenuItem> as data | The row has to know which item is which to keep one panel open at a time, and a list is what it can count. |
value / defaultValue | initialValue | String? has no way to tell "the caller did not say" from "the caller says closed", so a controlled mode would be one that could never be closed from outside. Which panel is open is the pointer's state, not the app's. |
| one panel that resizes between items | one panel per item, fading | The resize is Base UI measuring the outgoing and incoming panels and animating between them. Here each item anchors its own popup, so crossing the row swaps panels rather than growing one. |
target and a merged rel | — | There is no rel to protect, because there is no anchor. |
the <nav> landmark | SemanticsRole.navigation | The same landmark under the framework's own name. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |
Accessibility
- It is a real
<nav>full of real<a>s. That is the component's whole argument, and everything below follows from it. - Base UI owns the keyboard: the arrow keys move along the row, Enter and Space open a panel, Esc closes it and focus returns to the trigger, and Tab moves into an open panel's links.
- A trigger reports
aria-expanded, so a reader is told what pressing it will do. - A
disableditem keeps its word in the row and opens nothing. It is dimmed rather than recoloured, which is whatdisabledlooks like everywhere in the library. - The popup is portalled to the end of
<body>and its positioner carries.plass-portal, which is where a host that scopes a CSS reset hangs the same reset. - The chevron turns rather than the panel sliding. Nothing here moves under the pointer.