PlBreadcrumb
The trail of pages above the one being read. The last step is where the reader already is, so it stops being a link on its own, and a trail too long to read folds its middle away behind a ….
import { PlBreadcrumb, PlBreadcrumbItem } from 'plass-ui';
<PlBreadcrumb>
<PlBreadcrumbItem href="/">Home</PlBreadcrumbItem>
<PlBreadcrumbItem href="/settings">Settings</PlBreadcrumbItem>
<PlBreadcrumbItem>Billing</PlBreadcrumbItem>
</PlBreadcrumb>;import 'package:plass_ui/plass_ui.dart';
PlBreadcrumb(
items: <PlBreadcrumbItem>[
PlBreadcrumbItem(label: const Text('Home'), onPressed: goHome),
PlBreadcrumbItem(label: const Text('Settings'), onPressed: goSettings),
const PlBreadcrumbItem(label: Text('Billing')),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Type scale of the steps |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | The colour family a link picks up when it is hovered |
| densityshared | 'default' | 'compact' | 'default' | The gap between the steps, and nothing else |
| separator | 'chevron' | 'arrow' | 'slash' | 'dot' | ReactNode | 'chevron' | The mark between two steps. One of the four names, or any node. Four rather than a free-for-all because the difference is meaning, not decoration: chevron and arrow say "and then", slash says "path", dot says "peers of one thing" |
| maxItems | number | — | How many steps to show before the middle is folded away behind a …. Left out, the whole trail is shown however long it gets |
| itemsBeforeCollapse | number | 1 | How many steps stay at the front of a folded trail |
| itemsAfterCollapse | number | 1 | How many stay at the end |
| expandable | boolean | true | Whether pressing the … unfolds the trail in place. Turn it off to leave the fold as a plain mark |
| label | string | 'Breadcrumb' | The name the trail is announced by. Never drawn |
| expandLabel | string | 'Show the hidden steps' | What the … is announced as. Never drawn |
| structuredData | boolean | false | Emits the trail a second time as a BreadcrumbList in a script tag. Off by default — a page can only have one, and many apps already emit theirs. Every step goes in, including the ones a fold is hiding |
| baseUrl | string | — | What relative hrefs are resolved against for structuredData — the site’s origin. Search engines want an absolute URL there |
| children | ReactNode | — | The PlBreadcrumbItems |
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | List<PlBreadcrumbItem> | — | The steps, as a list of descriptions rather than children — the trail has to reason about which step is current and what a fold removes, and a Widget cannot be asked |
| sizeshared | PlassSize | PlassSize.md | Type scale of the steps |
| colorshared | PlassColor | PlassColor.primary | The colour family a link picks up when it is hovered |
| densityshared | PlassDensity | PlassDensity.standard | The gap between the steps, and nothing else |
| separator | PlBreadcrumbSeparator | PlBreadcrumbSeparator.chevron | The mark between two steps, as one of the four names |
| separatorWidget | Widget? | — | A mark of your own, which wins over separator |
| maxItems | int? | — | How many steps to show before the middle is folded away behind a …. Left out, the whole trail is shown however long it gets |
| itemsBeforeCollapse | int | 1 | How many steps stay at the front of a folded trail |
| itemsAfterCollapse | int | 1 | How many stay at the end |
| expandable | bool | true | Whether pressing the … unfolds the trail in place. Turn it off to leave the fold as a plain mark |
| label | String | 'Breadcrumb' | The name the trail is announced by. Never drawn |
| expandLabel | String | 'Show the hidden steps' | What the … is announced as. Never drawn |
Every native <nav> attribute passes straight through. color is excluded from the pass-through because it is a Plass prop here.
The steps are items rather than children, and a PlBreadcrumbItem is a description rather than a widget, Flutter's own idiom, the one DataColumn and BottomNavigationBarItem use. The reason is that the trail has to reason about its steps: which one is the current page, how many there are, and which ones a fold takes out. A Widget is opaque and none of those questions can be asked of one.
There is no variant and no elevation: a trail is a line of text above the page, not a surface laid on it.
PlBreadcrumbItem
| Prop | Type | Default | Description |
|---|---|---|---|
| href | string | — | Renders the step as a link |
| onClick | (event: MouseEvent) => void | — | Fires when the step is pressed. Renders it as a button when there is no href |
| startIcon | ReactNode | — | Content before the label — a home glyph, a repository avatar |
| endIcon | ReactNode | — | Content after the label |
| current | boolean | — | Marks this step as the page you are on, which stops it being a link. The last step is the current one on its own, so this is for a trail that ends somewhere the reader is not — and setting it anywhere takes the mark off the last step |
| disabledshared | boolean | false | Unavailable. Stops answering, keeps its place in the trail |
| children | ReactNode | — | The step’s label |
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | Widget | — | What the step says |
| onPressed | VoidCallback? | — | Fires when the step is pressed. Renders it as a button when there is no href |
| startIcon | Widget? | — | Content before the label — a home glyph, a repository avatar |
| endIcon | Widget? | — | Content after the label |
| current | bool? | — | Marks this step as the page you are on, which stops it being a link. The last step is the current one on its own, so this is for a trail that ends somewhere the reader is not — and setting it anywhere takes the mark off the last step |
| disabledshared | bool | false | Unavailable. Stops answering, keeps its place in the trail |
Every native <li> attribute passes straight through, onto the <li> rather than onto the link inside it. size is inherited from the PlBreadcrumb around it, a step that disagreed with its neighbours about the type scale would be a trail with a hole in it.
A step carries no size of its own. The trail sets the type scale for all of them, a step that disagreed with its neighbours about it would be a trail with a hole in it.
What the shared axes (size color density) mean across the library is in prop conventions.
Examples
The current step
The last step is the page you are on, so it is not a link at all: it is announced as where the reader is and stops being pressable, even with something to press. The component works that out rather than asking every caller to remember it.
Exactly one step in a trail may carry the mark, so a step that claims it with current takes it off the last one. Doing that by hand would mean writing current={false}current: false on a step that never asked for it.
import { PlBreadcrumb, PlBreadcrumbItem } from 'plass-ui';
export default function BreadcrumbCurrent() {
return (
<div className="flex flex-col gap-4">
<PlBreadcrumb>
<PlBreadcrumbItem href="#breadcrumb">Home</PlBreadcrumbItem>
<PlBreadcrumbItem href="#breadcrumb">Docs</PlBreadcrumbItem>
<PlBreadcrumbItem>The last step is the current one</PlBreadcrumbItem>
</PlBreadcrumb>
<PlBreadcrumb>
<PlBreadcrumbItem href="#breadcrumb">Home</PlBreadcrumbItem>
<PlBreadcrumbItem current href="#breadcrumb">
Claimed here instead
</PlBreadcrumbItem>
<PlBreadcrumbItem href="#breadcrumb">Still a link</PlBreadcrumbItem>
</PlBreadcrumb>
<PlBreadcrumb>
<PlBreadcrumbItem href="#breadcrumb">Home</PlBreadcrumbItem>
<PlBreadcrumbItem disabled href="#breadcrumb">
Unavailable
</PlBreadcrumbItem>
<PlBreadcrumbItem>Here</PlBreadcrumbItem>
</PlBreadcrumb>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class BreadcrumbCurrent extends StatelessWidget {
const BreadcrumbCurrent({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
PlBreadcrumb(
items: <PlBreadcrumbItem>[
PlBreadcrumbItem(label: const Text('Home'), onPressed: () {}),
PlBreadcrumbItem(label: const Text('Docs'), onPressed: () {}),
const PlBreadcrumbItem(label: Text('The last step is the current one')),
],
),
PlBreadcrumb(
items: <PlBreadcrumbItem>[
PlBreadcrumbItem(label: const Text('Home'), onPressed: () {}),
PlBreadcrumbItem(
label: const Text('Claimed here instead'),
current: true,
onPressed: () {},
),
PlBreadcrumbItem(label: const Text('Still a link'), onPressed: () {}),
],
),
PlBreadcrumb(
items: <PlBreadcrumbItem>[
PlBreadcrumbItem(label: const Text('Home'), onPressed: () {}),
PlBreadcrumbItem(label: const Text('Unavailable'), disabled: true, onPressed: () {}),
const PlBreadcrumbItem(label: Text('Here')),
],
),
],
);
}
}separator
Four named marks rather than a free-for-all, because a separator is read hundreds of times a day and the difference between them is meaning, not decoration: a chevron and an arrow say "and then", a slash says "path", a dot says "these are peers of one thing". Anything else can still be passed as a node.
The two that point are drawn once and turned, and they turn back under RTL. A trail runs the way the language does. Anything else goes in separatorseparatorWidget.
import { PlBreadcrumb, PlBreadcrumbItem } from 'plass-ui';
export default function BreadcrumbSeparators() {
return (
<div className="flex flex-col gap-4">
{(['chevron', 'arrow', 'slash', 'dot'] as const).map((separator) => (
<PlBreadcrumb key={separator} separator={separator}>
<PlBreadcrumbItem href="#breadcrumb">Home</PlBreadcrumbItem>
<PlBreadcrumbItem href="#breadcrumb">Docs</PlBreadcrumbItem>
<PlBreadcrumbItem>{separator}</PlBreadcrumbItem>
</PlBreadcrumb>
))}
<PlBreadcrumb separator={<span className="text-(--plass-muted-fg)">→</span>}>
<PlBreadcrumbItem href="#breadcrumb">Home</PlBreadcrumbItem>
<PlBreadcrumbItem href="#breadcrumb">Docs</PlBreadcrumbItem>
<PlBreadcrumbItem>a node of your own</PlBreadcrumbItem>
</PlBreadcrumb>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class BreadcrumbSeparators extends StatelessWidget {
const BreadcrumbSeparators({super.key});
@override
Widget build(BuildContext context) {
List<PlBreadcrumbItem> steps(String last) {
return <PlBreadcrumbItem>[
PlBreadcrumbItem(label: const Text('Home'), onPressed: () {}),
PlBreadcrumbItem(label: const Text('Docs'), onPressed: () {}),
PlBreadcrumbItem(label: Text(last)),
];
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final separator in PlBreadcrumbSeparator.values)
PlBreadcrumb(items: steps(separator.name), separator: separator),
PlBreadcrumb(
items: steps('a widget of your own'),
separatorWidget: PlTypography('→', color: PlassColor.secondary),
),
],
);
}
}maxItems
A trail seven levels deep is a trail nobody reads, so the middle collapses to a … that puts it back when pressed. itemsBeforeCollapse and itemsAfterCollapse decide how much stays at each end, and expandable={false}expandable: false leaves the fold as a plain mark.
The fold only happens when it actually removes something: on a three-step trail with one kept at each end, the … would stand in for exactly one step, which is longer than the step it replaced.
import { PlBreadcrumb, PlBreadcrumbItem } from 'plass-ui';
const steps = ['Home', 'Projects', 'Aurora', 'Services', 'Ingest', 'Settings'];
export default function BreadcrumbCollapse() {
return (
<div className="flex flex-col gap-4">
<PlBreadcrumb maxItems={4}>
{steps.map((step, index) => (
<PlBreadcrumbItem
key={step}
href={index === steps.length - 1 ? undefined : '#breadcrumb'}
>
{step}
</PlBreadcrumbItem>
))}
</PlBreadcrumb>
<PlBreadcrumb maxItems={4} itemsBeforeCollapse={2} itemsAfterCollapse={2} expandable={false}>
{steps.map((step, index) => (
<PlBreadcrumbItem
key={step}
href={index === steps.length - 1 ? undefined : '#breadcrumb'}
>
{step}
</PlBreadcrumbItem>
))}
</PlBreadcrumb>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<String> _steps = <String>[
'Home',
'Projects',
'Aurora',
'Services',
'Ingest',
'Settings',
];
class BreadcrumbCollapse extends StatelessWidget {
const BreadcrumbCollapse({super.key});
@override
Widget build(BuildContext context) {
List<PlBreadcrumbItem> trail() {
return <PlBreadcrumbItem>[
for (var index = 0; index < _steps.length; index += 1)
PlBreadcrumbItem(
label: Text(_steps[index]),
onPressed: index == _steps.length - 1 ? null : () {},
),
];
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
PlBreadcrumb(items: trail(), maxItems: 4),
PlBreadcrumb(
items: trail(),
maxItems: 4,
itemsBeforeCollapse: 2,
itemsAfterCollapse: 2,
expandable: false,
),
],
);
}
}structuredData
Correct markup alone is not what puts a path under a search result. The structured data is. structuredData emits the trail a second time as a schema.org BreadcrumbList, beside the <ol> rather than instead of it.
It is off by default, because a page can only have one of these and a great many apps already emit theirs from an SEO layer of their own. Turn it on where this component is the trail.
<PlBreadcrumb structuredData baseUrl="https://example.com">
<PlBreadcrumbItem href="/">Home</PlBreadcrumbItem>
<PlBreadcrumbItem href="/docs">Docs</PlBreadcrumbItem>
<PlBreadcrumbItem>Breadcrumb</PlBreadcrumbItem>
</PlBreadcrumb>Every step goes in, including the ones a maxItems fold is hiding: what is collapsed is a matter of how much room the row has, and the path is the path either way. baseUrl is what makes the URLs absolute, which is what a crawler wants.
size
import { PlBreadcrumb, PlBreadcrumbItem } from 'plass-ui';
export default function BreadcrumbSizes() {
return (
<div className="flex flex-col gap-3">
{(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((size) => (
<PlBreadcrumb key={size} size={size}>
<PlBreadcrumbItem href="#breadcrumb">Home</PlBreadcrumbItem>
<PlBreadcrumbItem href="#breadcrumb">Docs</PlBreadcrumbItem>
<PlBreadcrumbItem>{size}</PlBreadcrumbItem>
</PlBreadcrumb>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class BreadcrumbSizes extends StatelessWidget {
const BreadcrumbSizes({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final size in PlassSize.values)
PlBreadcrumb(
size: size,
items: <PlBreadcrumbItem>[
PlBreadcrumbItem(label: const Text('Home'), onPressed: () {}),
PlBreadcrumbItem(label: const Text('Docs'), onPressed: () {}),
PlBreadcrumbItem(label: Text(size.name)),
],
),
],
);
}
}Accessibility
- The trail is a
<nav>with an accessible name, holding an<ol>. The order is the meaning, so it is an ordered list. role="list"is written out because Tailwind's reset takes the bullets off every<ol>, and Safari takes the list semantics off with them.- The current step carries
aria-current="page"rather than"true". A trail is navigation, and the step the reader is on is a page, not the chosen one of a set of options. - The separators are
aria-hidden: a screen reader reading "greater-than" between every step is reading the punctuation. - A step with only an
onClickis a real<button>, and one with anhrefa real<a>. Neither is a<span>with a handler on it.
- The trail is a named group, and
labelis that name. - A step that goes somewhere is announced as a link, which is what puts it in a screen reader's list of links. Enter follows it; Space deliberately does not.
- The current step is announced as a heading rather than as a link. It is where the reader is, not somewhere to go.
- The separators are excluded from semantics: a screen reader reading "greater-than" between every step is reading the punctuation.
- The
…is a real focus stop with a name of its own, so a folded trail can be opened from a keyboard.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
<PlBreadcrumbItem> children | items, as descriptions | The trail has to reason about its steps, which is current, how many there are, which the fold takes out. A Widget is opaque; a description is not. |
href | onPressed | Flutter has no link element. A step that navigates calls your router. |
aria-current="page" | announced as a heading | Flutter's semantics tree has no current. A heading is the nearest true thing: this is the place, not a way to it. |
structuredData, baseUrl | — | There is no crawler reading a Flutter app, and no <script type="application/ld+json"> to put a BreadcrumbList in. |
separator as name-or-node | separator and separatorWidget | Dart has no union type, so the named marks and a mark of your own are two parameters. |
children on a step | label | It is the one slot, and naming it is what lets a step be a description. |