Toolbar
A bar of controls: an application header, a page's action row, the strip along the bottom of an editor.
import { Toolbar } from 'neba';
<Toolbar render={<header />} start={<Logo />} end={<Button>Deploy</Button>}>
Workspace
</Toolbar>;Three slots and a row. start and end are pinned to their ends and children 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 <div> they have to remember.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'outline' | 'text' | 'outline' | Weight of the bar, said the way a container says it — the sheet is never dyed, because a toolbar holds other people’s controls and those arrive with colours of their own |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The scale of the padding and the radius. Not a height: a toolbar is as tall as the controls in it plus its padding |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only — which is why there is no separate `dense` prop meaning the same thing |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. `0` even when the bar is pinned: a shadow under a header says "there is content beneath this", and that is only true once the page has been scrolled |
| positionshared | 'static' | 'sticky' | 'fixed' | 'static' | How the bar sits in the page’s scroll. `sticky` takes up its own space and stops at the edge, so nothing underneath has to be padded around it; `fixed` leaves the flow entirely, so the page needs padding of its own |
| sideshared | '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 — under a `top` bar, over a `bottom` one |
| 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 (render={<header />}). Base UI's own escape hatch |
| children | ReactNode | — | The middle. Takes whatever width `start` and `end` leave |
Every native <div> attribute passes through.
Examples
Density
A Toolbar has no height of its own. It 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 you the dense bar without a second prop meaning the same thing, and without the type scale moving.
import { Button, ButtonGroup, TextField, Toolbar, Typography } from 'neba';
const DENSITIES = ['default', 'compact'] as const;
/**
* A Toolbar has no height of its own. It is as tall as the controls in it plus
* its padding — and `density` is that padding, which is the only thing it is
* ever allowed to change.
*/
export default function ToolbarDensity() {
return (
<div className="flex w-full flex-col gap-5">
{DENSITIES.map((density) => (
<div key={density} className="flex flex-col gap-2">
<Typography level="caption">density="{density}"</Typography>
<Toolbar
density={density}
className="w-full"
start={<Typography level="h6">Files</Typography>}
end={
<ButtonGroup variant="outline" size="sm" color="secondary">
<Button>Rename</Button>
<Button>Move</Button>
</ButtonGroup>
}
>
<TextField size="sm" placeholder="Filter…" fullWidth />
</Toolbar>
</div>
))}
</div>
);
}Pinned
position is CSS's own three values, spelled the way CSS spells them.
stickyis what an application header usually wants: it takes up its own space, so nothing underneath has to be padded around it.fixedleaves the flow entirely, so the page needs padding of its own or the first screenful sits behind the bar.
A pinned bar drops its radius, because a rounded corner against the edge of the screen is a gap with nothing behind it. divider gives it the hairline that says there is content beneath.
import { Button, Toolbar, Typography } from 'neba';
/**
* `position="sticky"` is what an application header usually wants: it takes up
* its own space, so nothing underneath has to be padded around it. `divider`
* gives it the rule that says there is content beneath.
*
* The box below scrolls; the bar does not.
*/
export default function ToolbarSticky() {
return (
<div className="h-64 w-full overflow-y-auto rounded-(--neba-radius-md) [border:1px_solid_var(--neba-border)]">
<Toolbar
position="sticky"
divider
variant="solid"
density="compact"
start={<Typography level="h6">Changelog</Typography>}
end={
<Button size="sm" variant="text">
Subscribe
</Button>
}
/>
<div className="flex flex-col gap-3 p-4">
{Array.from({ length: 12 }, (_, index) => (
<Typography key={index} level="body">
{`1.0.${12 - index} — a release note long enough to make the page scroll under the bar.`}
</Typography>
))}
</div>
</div>
);
}elevation stays 0 even when the bar is pinned. 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 — so raise it yourself on scroll, or leave it flat and turn on divider.
Why there is no role="toolbar"
That role 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 page header wants is render={<header />} — a real landmark, which is the thing a screen reader user will actually navigate by. What a genuine roving-focus toolbar wants is a ButtonGroup, which is one.
Coming from Material UI
| MUI | Neba |
|---|---|
<AppBar><Toolbar>…</Toolbar></AppBar> | One component. position is on it directly |
position="sticky" | The same. 'static', 'sticky', 'fixed' |
variant="dense" | density="compact" — padding, which is all density is ever allowed to change |
color="primary" | color reaches the hairline and the focus ring. A bar that holds other people's controls does not dye its own sheet |
elevation={4} | elevation is 0–3, and 0 means no shadow at all |
<Box sx={{ flexGrow: 1 }} /> as a spacer | Not needed. start, children and end are the three slots |