PlSidebar
A column beside the page's content, and a drawer once the window is too narrow to hold one. Two presentations of one panel, so a caller never swaps components at a breakpoint.
import { PlPageLayout, PlSidebar } from 'plass-ui';
<PlPageLayout sidebar={<PlSidebar label="Main navigation">{nav}</PlSidebar>}>{page}</PlPageLayout>;import 'package:plass_ui/plass_ui.dart';
PlPageLayout(
sidebar: PlSidebar(semanticLabel: 'Main navigation', child: navigation),
child: page,
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the panel is made of. Never dyed — what is on it arrives with colours of its own |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The panel's default width and the air around its content |
| 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 height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| side | 'start' | 'end' | 'start' | Which end of the band it takes. Logical rather than physical. Inside a PlPageLayout the slot it was handed to has already decided this |
| width | number | string | — | How wide the column is — a number in pixels or any CSS length. Left out, it is the width size implies |
| minWidth | number | string | 160 | How narrow it may be dragged |
| maxWidth | number | string | 480 | And how wide |
| resizable | boolean | false | Lets the reader drag the inner edge to change the column's width |
| onResize | (width: number) => void | — | Fires with the width in pixels while the edge is being dragged |
| onResizeEnd | (width: number) => void | — | Fires once, with the same number, when it is let go |
| collapseBelow | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none' | — | The window width below which it becomes a drawer instead of a column. Defaults to the PlPageLayout's own, and to none outside one |
| open | boolean | — | Whether the drawer is open. Only meaningful once collapsed; inside a PlPageLayout the layout owns it |
| defaultOpen | boolean | false | Which state it starts in, for an uncontrolled standalone sidebar |
| onOpenChange | (open: boolean) => void | — | Called when the drawer opens or closes |
| sticky | boolean | true | Whether the column holds its place while the page scrolls past it. It costs nothing when it is not needed |
| title | ReactNode | — | The heading, drawn only while the sidebar is a drawer. A column has the page around it to say what it is; a panel that has covered the page does not |
| divider | boolean | true | Draws a hairline down the inner edge — the one facing the content |
| padded | boolean | true | The gutter and the air above and below the content |
| label | string | 'Sidebar' | The name the region is announced by. A page with two sidebars must write one |
| closeLabel | string | 'Close sidebar' | What the drawer's close button says, once the sidebar has collapsed |
| resizeLabel | string | 'Resize sidebar' | What the drag handle is announced as |
| children | ReactNode | — | Everything in it: a nav, a filter panel, a table of contents |
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | PlassVariant | PlassVariant.glass | What the panel is made of. Never dyed — what is on it arrives with colours of its own |
| sizeshared | PlassSize | PlassSize.md | The panel's default width and the air around its content |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth. 0 means no shadow at all |
| side | PlassSidebarSide? | PlassSidebarSide.start | Which end of the band it takes. Logical rather than physical. Inside a PlPageLayout the slot it was handed to has already decided this |
| width | double? | — | How wide the column is — a number in pixels or any CSS length. Left out, it is the width size implies |
| minWidth | double | 160 | How narrow it may be dragged |
| maxWidth | double | 480 | And how wide |
| resizable | bool | false | Lets the reader drag the inner edge to change the column's width |
| onResize | ValueChanged<double>? | — | Fires with the width in pixels while the edge is being dragged |
| onResizeEnd | ValueChanged<double>? | — | Fires once, with the same number, when it is let go |
| collapseBelow | PlassBreakpoint? | — | The width below which the column becomes a drawer, measured against the window. Left out, the PlPageLayout above decides — and outside a layout it never collapses |
| open | bool? | — | Whether the drawer is open. Only meaningful once collapsed; inside a PlPageLayout the layout owns it |
| onOpenChanged | ValueChanged<bool>? | — | Called when the drawer opens or closes |
| title | Widget? | — | The heading, drawn only while the sidebar is a drawer. Left out, semanticLabel is drawn as the heading instead: a panel that has covered the screen is named by what it draws |
| divider | bool | true | Draws a hairline down the inner edge — the one facing the content |
| padded | bool | true | The gutter and the air above and below the content |
| semanticLabel | String | 'Sidebar' | The name the region is announced by. A page with two sidebars must write one |
| closeLabel | String | 'Close sidebar' | What the drawer's close button says, once the sidebar has collapsed |
| resizeLabel | String | 'Resize sidebar' | What the drag handle is announced as |
| child | Widget? | — | Everything in it: a nav, a filter panel, a table of contents |
Every native <aside> attribute passes straight through. color and title are excluded because both are Plass props here.
PlSidebarTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| side | 'start' | 'end' | 'start' | Which of the layout's two sidebars it opens |
| collapseBelow | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none' | — | The width below which the button appears — the same one the sidebar collapses at. Inherited from the PlPageLayout |
| icon | ReactNode | — | The glyph. A hamburger, drawn here, unless something else is given |
| label | string | 'Open sidebar' / 'Close sidebar' | What it does, in words |
| Prop | Type | Default | Description |
|---|---|---|---|
| side | PlassSidebarSide | PlassSidebarSide.start | Which of the layout's two sidebars it opens |
| icon | Widget? | — | The glyph. A hamburger, drawn here, unless something else is given |
| label | String? | 'Open sidebar' / 'Close sidebar' | What it does, in words |
| variantshared | PlassVariant | PlassVariant.ghost | What the key is made of. ghost by default: it sits on a bar that is already a sheet |
| sizeshared | PlassSize | PlassSize.md | The key's size |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role |
Everything else is PlIconButton's, unchanged.
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
Two shapes, one panel
Above collapseBelow the sidebar is an <aside> in the layout that the content is laid out around. Below it, the same children are a PlDrawer over a scrim, with a focus trap, an Esc and a way back to the trigger.
They are one component because they are one thing, and because the children then exist once either way, rather than being rendered twice into the document for a screen reader to read twice.
Which of the two is showing is a media query, answered in CSS for the first paint and in JavaScript from then on. The markup a server sends is the column, so a narrow screen would otherwise draw a full-width sidebar and throw it away a moment later. The class that hides it below the breakpoint is what stops that, and matchMedia decides, once there is a window to ask, that the drawer should exist at all.
Examples
side
Logical rather than physical: start is the left of an English page and the right of an Arabic one, because a navigation rail is beside the text it belongs to in every writing direction.
Inside a PlPageLayout it is already decided by which slot the sidebar was handed to, and setting it again is only a way of disagreeing with the layout.
import { PlPageLayout, PlSidebar } from 'plass-ui';
export default function SidebarSides() {
return (
<div className="h-56 w-full overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="none"
sidebar={
<PlSidebar size="xs" width={140} label="Navigation">
<span className="text-xs">Navigation</span>
</PlSidebar>
}
endSidebar={
<PlSidebar size="xs" width={140} label="On this page">
<span className="text-xs">On this page</span>
</PlSidebar>
}
>
<p className="p-5 text-sm">
Two columns, one on each end. Neither needs a <code>side</code> of its own: the slot it
was handed to is what decides.
</p>
</PlPageLayout>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarSides extends StatelessWidget {
const SidebarSides({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 520,
height: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: const PlPageLayout(
collapseBelow: null,
sidebar: PlSidebar(
size: PlassSize.xs,
width: 140,
semanticLabel: 'Navigation',
child: Text('Navigation'),
),
endSidebar: PlSidebar(
size: PlassSize.xs,
width: 140,
semanticLabel: 'On this page',
child: Text('On this page'),
),
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'Two columns, one on each end. Neither needs a side of its own: the slot it was '
'handed to is what decides.',
),
),
),
),
);
}
}collapseBelow
The window width below which the column becomes a drawer. It defaults to the layout's own collapseBelow, and to none outside a layout, a sidebar that collapsed with nothing on the page able to bring it back would be a sidebar the reader has lost.
PlSidebarTrigger is what brings it back. Put it in a PlHeader's brand slot, ahead of the logo, which is where thirty years of hamburgers have taught readers to look. It is hidden by the same media query rather than by a piece of state, so it is in the markup a server sends rather than popping into the header a moment after the page arrives.
title is drawn only while the sidebar is a drawer: a column has the page around it to say what it is, and a panel that has covered the page does not.
import { PlHeader, PlPageLayout, PlSidebar, PlSidebarTrigger } from 'plass-ui';
export default function SidebarCollapse() {
return (
<div className="h-64 w-full overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="lg"
header={
<PlHeader
size="sm"
brand={
<>
<PlSidebarTrigger size="sm" />
<span className="font-semibold">Acme</span>
</>
}
/>
}
sidebar={
<PlSidebar size="sm" label="Main navigation" title="Navigation">
<nav className="flex flex-col gap-2 text-sm">
{['Overview', 'Reports', 'Settings'].map((item) => (
<a key={item} href="#" className="no-underline">
{item}
</a>
))}
</nav>
</PlSidebar>
}
>
<p className="p-5 text-sm">
Below <code>lg</code> the column is a drawer and the hamburger is what brings it back.
Widen the window past 64rem and the button goes away with it.
</p>
</PlPageLayout>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarCollapse extends StatelessWidget {
const SidebarCollapse({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 360,
height: 280,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: const PlPageLayout(
collapseBelow: PlassBreakpoint.md,
header: PlHeader(
size: PlassSize.sm,
brand: <Widget>[
PlSidebarTrigger(size: PlassSize.sm),
Text('Acme', style: TextStyle(fontWeight: FontWeight.w600)),
],
),
sidebar: PlSidebar(
size: PlassSize.sm,
semanticLabel: 'Main navigation',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: <Widget>[Text('Overview'), Text('Reports'), Text('Settings')],
),
),
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'This frame is narrower than md, so the column is a drawer and the hamburger is '
'what brings it back.',
),
),
),
),
);
}
}resizable
Off by default. A sidebar that can be resized is a sidebar whose width is the reader's to remember, so a caller who turns this on usually also stores what onResizeEnd reports.
The dragged width is written straight onto the element rather than into state: nothing in the tree depends on the number except one CSS declaration, and a setState per pointer move would re-render every row in the panel. The caller still hears every step through onResize.
The handle straddles the edge rather than sitting inside it, a hairline one pixel wide is a target one pixel wide, which is the same split between what is drawn and what can be grabbed that a scrollbar makes.
import { useState } from 'react';
import { PlPageLayout, PlSidebar } from 'plass-ui';
export default function SidebarResizable() {
const [width, setWidth] = useState(220);
return (
<div className="h-56 w-full overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="none"
sidebar={
<PlSidebar
size="sm"
label="Files"
resizable
width={220}
minWidth={140}
maxWidth={320}
onResize={setWidth}
>
<span className="text-sm">Drag the inner edge.</span>
</PlSidebar>
}
>
<p className="p-5 text-sm">
The column is <strong>{Math.round(width)}px</strong> wide. The handle straddles the edge
rather than sitting inside it, and the arrow keys move it too.
</p>
</PlPageLayout>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarResizable extends StatefulWidget {
const SidebarResizable({super.key});
@override
State<SidebarResizable> createState() => _SidebarResizableState();
}
class _SidebarResizableState extends State<SidebarResizable> {
double _width = 220;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 520,
height: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: PlPageLayout(
collapseBelow: null,
sidebar: PlSidebar(
size: PlassSize.sm,
semanticLabel: 'Files',
resizable: true,
width: 220,
minWidth: 140,
maxWidth: 320,
onResize: (double width) => setState(() => _width = width),
child: const Text('Drag the inner edge.'),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(
'The column is ${_width.round()} wide. The handle straddles the edge rather than '
'sitting inside it, and the arrow keys move it too.',
),
),
),
),
);
}
}variant
The three materials, read the way a container reads them. The panel is never dyed: what is on a sidebar is somebody's navigation, and it arrives with colours of its own.
divider rules the inner edge, the one facing the content. The outer edge is against the window, where there is nothing on the other side to be separated from.
import { PlPageLayout, PlSidebar, type PlassVariant } from 'plass-ui';
export default function SidebarVariants() {
return (
<div className="grid w-full gap-4 sm:grid-cols-3">
{(['solid', 'glass', 'ghost'] as PlassVariant[]).map((variant) => (
<div key={variant} className="h-40 overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="none"
sidebar={
<PlSidebar size="xs" width={90} variant={variant} label={variant}>
<span className="text-xs">{variant}</span>
</PlSidebar>
}
>
<p className="p-3 text-xs">The panel is never dyed.</p>
</PlPageLayout>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarVariants extends StatelessWidget {
const SidebarVariants({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 16,
children: <Widget>[
for (final PlassVariant variant in PlassVariant.values)
SizedBox(
width: 220,
height: 150,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: PlPageLayout(
collapseBelow: null,
sidebar: PlSidebar(
size: PlassSize.xs,
width: 90,
variant: variant,
semanticLabel: variant.name,
child: Text(variant.name),
),
child: const Padding(
padding: EdgeInsets.all(12),
child: Text('The panel is never dyed.'),
),
),
),
),
],
);
}
}sticky
On by default, and it costs nothing when it is not needed. With the page scrolling, the column is sticky and as tall as what is left of the window under the header, which is what --p-layout-header and --p-layout-footer are measured for. With only the content scrolling, the column is already as tall as the layout and this changes nothing.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
collapseBelow against the window, defaulting to the layout's | the same, but the layout's answer is against its own width | A LayoutBuilder sees the constraints the layout was handed; a media query only ever sees the window. Set it here and the window is what is measured, which is the override. |
'none' | null | Dart's way of saying "no floor was named". |
| the trigger hidden by a media query | the trigger not built at all | The class exists on the web to keep the button in the markup a server sends. There is no first paint to hold together here. |
sticky | — | The column is as tall as the band the layout gave it. There is no document scroll for it to hold its place against. |
title falling back to an aria-label | title falling back to semanticLabel, drawn | A PlDrawer is named by what it draws, so the region's name becomes its heading rather than an invisible label. |
a role="separator" handle with aria-valuenow | a Semantics(slider: true) handle with a value in logical pixels | Flutter's semantics tree has no separator role and no valuenow. A handle is what it actually is: a control with a value that can be turned up and down. |
| the width written onto the element | the width in a ValueNotifier | Same decision, different spelling: nothing but one box depends on the number, and rebuilding the panel on every pointer move would rebuild every row in it. |
label | semanticLabel | Flutter's name. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |
Accessibility
- The column is a real
<aside>, which is thecomplementarylandmark. labelis required in practice and defaults toSidebar. A page with two sidebars must give each one a name, or a screen reader offers two regions called "complementary".- Collapsed, it is a dialog: focus is trapped, Esc closes it, the page behind it is inert, and focus returns to whatever opened it. All of that is
PlDrawer's, which is Base UI's. - The trigger carries
aria-expanded, so a screen reader is told whether the panel is open before it is pressed. - The resize handle is a
role="separator"witharia-orientation="vertical", a tab stop whileresizable, and moved by ← →. A key press firesonResizeEndas well asonResize, because it is a whole gesture on its own. - A drag takes the page's text selection away as
-webkit-user-select, the only name WebKit implements, rather than callingpreventDefault, which would stop the browser focusing the handle.
- The column claims
SemanticsRole.complementary, the same landmark the<aside>tag carries on the other side. semanticLabelnames it and defaults toSidebar. A screen with two sidebars must name each one: Flutter refuses a duplicated landmark with no label outright.- Collapsed, it is a
PlDrawer: focus is trapped, the barrier dismisses it, and focus returns to whatever opened it. - The resize handle is a
Semantics(slider: true)with the width as its value andonIncrease/onDecreasewired to the same step the arrow keys use, so a screen reader can move the edge without a pointer. - The trigger is a real
PlIconButtonwith a name that says what pressing it will do.