PlToolbar
A bar of controls: an application header, a page's action row, the strip along the bottom of an editor. Three slots and a row.
import { PlButton, PlToolbar, PlTypography } from 'plass-ui';
<PlToolbar
render={<header />}
start={<PlTypography level="h6">Reports</PlTypography>}
end={<PlButton>New</PlButton>}
/>;import 'package:plass_ui/plass_ui.dart';
PlToolbar(
start: const <Widget>[PlTypography('Reports', level: PlTypographyLevel.h6)],
end: <Widget>[PlButton(onPressed: create, child: const Text('New'))],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the bar is made of. Never dyed: a toolbar holds other people's controls |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The bar's padding and radius. The height is whatever the controls in it need |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only — never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 even when pinned: a shadow under a header is only true once the page has scrolled |
| position | 'static' | 'sticky' | 'fixed' | 'static' | How the bar sits in the page's scroll. sticky takes up its own space; fixed leaves the flow |
| side | 'top' | 'bottom' | 'top' | Which edge it is held against when position is not static |
| divider | boolean | false | Draws a hairline along the edge that faces the content |
| start | ReactNode | — | Pinned to the start of the bar: a logo, a title, a back button |
| end | ReactNode | — | Pinned to the end: the actions |
| render | useRender.RenderProp | — | Renders something other than a div — a header, a nav |
| Prop | Type | Default | Description |
|---|---|---|---|
| child | Widget? | — | The middle. Takes whatever width start and end leave |
| start | List<Widget>? | — | Pinned to the start of the bar: a logo, a title, a back button. Dart has no fragment, so the slot takes a list and spaces it |
| end | List<Widget>? | — | Pinned to the end: the actions |
| divider | bool | false | Draws a hairline along the edge that faces the content |
| side | PlassSide | PlassSide.top | Which way the bar is facing, and the one thing that depends on it: which edge the divider is drawn along |
| rounded | bool | true | Whether the bar is a sheet with corners. Turn it off for one held against an edge of the screen: a rounded corner there is a gap with nothing behind it |
| variantshared | PlassVariant | PlassVariant.glass | What the bar is made of. Never dyed: a toolbar holds other people's controls |
| sizeshared | PlassSize | PlassSize.md | The bar's padding and radius. The height is whatever the controls in it need |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the type scale |
| elevationshared | int | 0 | Drop shadow depth. 0 even when pinned: a shadow under a header is only true once the page has scrolled |
| semanticLabel | String? | — | The name a screen reader gives the bar, if it needs one of its own |
Every other <div> attribute passes through, and render swaps the element.
What the shared axes mean across the library is in prop conventions.
Height
A toolbar is as tall as the controls in it plus its padding, and that padding is the size / density pair every other surface uses. So density="compact" gives the dense bar without a second prop meaning the same thing, and without the type scale moving under it.
import { PlButton, PlToolbar, PlTypography } from 'plass-ui';
export default function ToolbarDensity() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
{(['default', 'compact'] as const).map((density) => (
<PlToolbar
key={density}
density={density}
start={<PlTypography level="caption">density: {density}</PlTypography>}
end={
<PlButton size="sm" density={density}>
Save
</PlButton>
}
/>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ToolbarDensity extends StatelessWidget {
const ToolbarDensity({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final PlassDensity density in PlassDensity.values)
PlToolbar(
density: density,
start: <Widget>[
PlTypography('density: ${density.name}', level: PlTypographyLevel.caption),
],
end: <Widget>[
PlButton(
size: PlassSize.sm,
density: density,
onPressed: () {},
child: const Text('Save'),
),
],
),
],
);
}
}No toolbar role
This is deliberate. role="toolbar", and the semantics behind it, is a promise about keyboard behaviour: one tab stop for the whole bar, arrow keys between the controls in it. A bar that claims it without implementing it is worse for a keyboard reader than one that never claimed anything.
What a genuine roving-focus set of choices wants is a PlSegmentedButton, which is one.
What a page header wants is the right element: render={<header />}.
Examples
The three slots
start and end are pinned to their ends and the middle takes what is left, which is the arrangement every toolbar has ever had, so it is laid out here rather than left to a caller and a spacer they have to remember. The middle keeps its width even when it is empty, or the two ends collapse together in the middle of the bar.
import { PlButton, PlSegment, PlSegmentedButton, PlToolbar, PlTypography } from 'plass-ui';
export default function ToolbarSlots() {
return (
<PlToolbar
className="w-full max-w-lg"
divider
start={<PlTypography level="h6">Invoices</PlTypography>}
end={<PlButton size="sm">Export</PlButton>}
>
<PlSegmentedButton size="sm" defaultValue="all">
<PlSegment value="all">All</PlSegment>
<PlSegment value="open">Open</PlSegment>
<PlSegment value="paid">Paid</PlSegment>
</PlSegmentedButton>
</PlToolbar>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ToolbarSlots extends StatefulWidget {
const ToolbarSlots({super.key});
@override
State<ToolbarSlots> createState() => _ToolbarSlotsState();
}
class _ToolbarSlotsState extends State<ToolbarSlots> {
String _filter = 'all';
@override
Widget build(BuildContext context) {
return PlToolbar(
divider: true,
start: const <Widget>[PlTypography('Invoices', level: PlTypographyLevel.h6)],
end: <Widget>[PlButton(size: PlassSize.sm, onPressed: () {}, child: const Text('Export'))],
child: PlSegmentedButton<String>(
size: PlassSize.sm,
semanticLabel: 'Filter',
value: _filter,
onChanged: (String next) => setState(() => _filter = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'all', label: Text('All')),
PlSegment<String>(value: 'open', label: Text('Open')),
PlSegment<String>(value: 'paid', label: Text('Paid')),
],
),
);
}
}variant
The three materials, read as a container's: the bar is never dyed, exactly as on a PlBox. A toolbar holds other people's controls, and those controls arrive with colours of their own.
import { PlButton, PlToolbar, PlTypography } from 'plass-ui';
export default function ToolbarVariants() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
{(['glass', 'solid', 'ghost'] as const).map((variant) => (
<PlToolbar
key={variant}
variant={variant}
start={<PlTypography level="caption">{variant}</PlTypography>}
end={
<PlButton size="sm" variant="ghost">
Action
</PlButton>
}
/>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ToolbarVariants extends StatelessWidget {
const ToolbarVariants({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final PlassVariant variant in PlassVariant.values)
PlToolbar(
variant: variant,
start: <Widget>[PlTypography(variant.name, level: PlTypographyLevel.caption)],
end: <Widget>[
PlButton(
size: PlassSize.sm,
variant: PlassVariant.ghost,
onPressed: () {},
child: const Text('Action'),
),
],
),
],
);
}
}Held against an edge
static leaves the bar in the flow. sticky holds it against an edge once the page has scrolled that far, and it still takes up its own space, so nothing underneath has to be padded around it. fixed takes it out of the flow entirely, and the page then needs padding of its own or the first screenful sits behind the bar.
A pinned bar loses its corners: a rounded corner against the edge of the screen is a gap with nothing behind it.
There is no position here, for the reason PlFloatingBottomNavigation has none: a fixed element has to span something, and a Flutter widget goes exactly where the screen puts it. A bar that has to stay put belongs in the screen's own layout, a Stack with a Positioned, or the top of a Column with the content scrolling under it.
What is left is the one visible consequence: rounded. On for a bar sitting in the layout, off for one held against an edge, because a rounded corner against the edge of the screen is a gap with nothing behind it.
side then decides one thing only: which edge divider draws its hairline along, under a top bar, over a bottom one.
elevation stays at 0 even pinned, which is deliberate. A shadow under a header is a way of saying "there is content beneath this", and that is only true once the page has been scrolled. Raise it yourself at that moment, or leave it flat and turn on divider.
Accessibility
- The bar claims no role of its own.
- The controls inside it are ordinary controls in reading order, each with its own focus stop, which is what a bar that has not promised roving focus owes a keyboard reader.
- What the bar is is decided by the element it renders.
render={<header />}andrender={<nav />}are the two that come up most; a page's header should be a<header>.
semanticLabelnames the bar itself when it needs a name of its own. The controls inside keep their own nodes, so the name is the bar's rather than the bar's and everything in it read as one blob.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
position | — | A fixed element has to span something. A Flutter widget goes exactly where the screen puts it, and a bar that has to stay put belongs in the screen's own layout. |
corners follow position | rounded | The same call, made directly: on in the flow, off against an edge. |
side picks the pinned edge and the rule's edge | side picks the rule's edge | Nothing else is left for it to decide. |
render | The element you build it inside | There is no element to swap. semanticLabel is what names the bar. |
start, end as one node | List<Widget> | Dart has no fragment, so the slot takes the list it was going to hold anyway, and spaces it. |
children | child | One slot, and Dart spells it child. |
className, style | — | There is no class list and no style attribute to pass through. |