A bar is a horizontal strip at the top of a content region. It carries the page's identity — a breadcrumb, a title, the close — or the instrument panel for the thing below it — a size, a format, a colour, a view. Same box either way; what differs is configuration.
It used to be two components. and were the same shape discovered twice — same outer/inner split, same trailing cluster, same page-column variant — and they had already converged from both ends before the merge on 2026-08-14. The old distinction ("a sticky bar describes the page, a toolbar changes the thing below it") survives as guidance for what you put in a bar, not as a component boundary.
It is named for its form, like Card, Drawer and Dialog — the class names the box, and the ARIA role names the contents. A bar of controls may take role="toolbar" only if the arrow-key behaviour that role promises is actually implemented (this component does not); a bar holding a breadcrumb wraps it in <nav> and must not.
It is a container, and almost nothing else. Every control that goes in one is already a component — button, dropdown, segmented control, toggle, number input, divider. The bar's job is to group them, label the groups, push the actions to the end, and keep everything reachable when space runs out.
It is the counterpart to the Site Header, not a variant of it. A header carries site identity and global navigation; a bar carries what belongs to the page you are on, and docks below the header rather than beside it.
Anatomy
div.bar data-density="regular|compact" data-width="docs|tool" data-sticky="true"
div.bar-container the row; one row, always — see Overflow
div.bar-content leading region: breadcrumb, title, or control groups
div.bar-group a labelled cluster data-priority="pinned|menu"
span.bar-label hidden below 768px
<any control>
hr.divider.divider--vertical
div.bar-actions trailing cluster, pushed over with an auto margin
button.button pinned actions
div.dropdown.bar-overflow the "more" trigger, hidden until needed
a.bar-close the exit; always last
The outer element owns background and border so the rule runs the full width; the inner owns the row. That split is what lets a bar sit inside a padded shell without the border stopping short of the edges.
is the region overflow manages. It does not clip — a menu opened from inside it crosses the bar's edge, and a single too-wide item truncates itself rather than scrolling. holds what never collapses, and a sitting directly inside it renders as a full-height cell, the same shape as the close, with its panel hanging off the bar's bottom border. The close is still always last — anything placed after it turns its divider into a mistake.
Density
Two presets, both re-points of --bar-padding-y underneath.
data-density="regular" is a fixed height — --bar-height, 70px — for a bar whose content is uniform: a breadcrumb and a close. This is the doc page sub-header. The fixed height is also what makes the full-height stretch of the close — and of every menu cell in the actions cluster — safe.
data-density="compact" collapses the bar to exactly one control tall, for a full-height app shell where every row given to chrome is a row taken from the canvas.
The default (no attribute) is one control tall plus --bar-padding-y of breathing room either side — 52px at today's values. This is what the tool control bars use.
<div class="bar" data-density="regular">…</div> <!-- 70px, uniform content -->
<div class="bar">…</div> <!-- one control + padding -->
<div class="bar" data-density="compact">…</div> <!-- exactly one control -->
Width
The component is full-bleed. Two shell variants in docs-site.css constrain the contents to a content column while the border keeps running edge to edge:
| Attribute | Column | Used by |
|---|---|---|
data-width="docs" |
1080px — the docs reading frame | every doc page's sub-header |
data-width="tool" |
plus the bar's own inline padding | framed tools' control bars |
| (none) | full width, gutters | full-bleed app tools |
The tool calc adds the bar's inline padding back onto the container width so the first control lands on the same left edge as the tool content beneath the bar. These are shell variants, not part of the component — a different product's shell defines its own columns.
The close
The exit lives at the bar's trailing edge: a full-height cell with a single left divider, welded to the container's edge (the container drops its inline-end padding when a close is present). A menu cell in the actions cluster takes the same full-height shape but carries no divider of its own — the close's left divider stays the only vertical rule at the trailing edge, so the cells read as one cluster ending in the exit.
<div class="bar-actions">
<a href="../index.html" class="bar-close" data-page-close aria-label="Back to Tools">
{{icon:close-large}}
</a>
</div>
Three rules:
- The close is always last. Anything after it gives its divider one side only, which reads as a bug. The overflow trigger goes before it — a full-height cell too, but an undivided one.
- It is an
<a>, never a<button>. On generated pagesdata-page-closeis a build-time contract — nothing reads it at runtime, so a button is a dead end. - One bar per page, one exit on it. A doc page's bar carries a breadcrumb and the close; a tool's carries its controls and the close. Do not stack a second bar to hold the exit — the close belongs on the bar the page already has.
The close is safe at any density because the bar is always one row — see Overflow.
Overflow
The bar is always one row. It neither wraps nor lets a set of controls scroll out of reach. Items in that do not fit move into the overflow menu — a dropdown carrying the class in — and move back when space returns. assets/js/bar.js does the moving; open/close belongs to dropdown.js.
Priority is authored on the items, not inferred:
data-priority |
Behaviour |
|---|---|
pinned |
Never leaves the bar |
| (omitted) | Collapses into the menu when space runs out |
menu |
Always in the menu, whatever the space |
<div class="bar-content">
<div class="bar-group">…collapses under pressure…</div>
<div class="bar-group" data-priority="pinned">…never leaves…</div>
<div class="bar-group" data-priority="menu">…always in the menu…</div>
</div>
<div class="bar-actions">
<button class="button" type="button">Export</button>
<div class="dropdown bar-overflow" hidden>
<!-- No aria-haspopup: the filled panel is a disclosure of relocated
controls, not a role="menu" -->
<button class="dropdown-trigger" type="button"
aria-expanded="false" aria-label="More controls">{{icon:more-horizontal}}</button>
<div class="dropdown-menu is-right"></div>
</div>
</div>
How it behaves, and why:
- Whole items move, in trailing order. A group is one idea — four switches captioned "Show" — and splitting it across bar and menu would read as two.
- Demoted items sit in a stable block at the top of the panel, a separator below them, with overflow appended underneath. An item you deliberately demoted must not change position every time the window resizes.
- The trigger stays hidden until there is something to show. No empty affordance.
- The primary action is pinned by position — never collapses. Pinning exempts an item from the menu, not from responsiveness: a labelled button may still shed its label via
data-icon-onlyat narrow widths. - A single item too wide for the bar is not a collapse case — there is nothing to move, so it truncates itself. The breadcrumb is the working example: the current page clips with an ellipsis, the section link holds one line. deliberately does not scroll or clip — it was a hidden-scrollbar scroll container once, and the clip silently swallowed every menu opened from inside it.
- A dropdown that travels into the panel becomes a disclosure. Its floating menu is positioned against its own wrapper, which points at nothing inside a floating panel — so in the panel the menu goes static and expands in place, pushing the panel taller. dropdown.js keeps the panel open while a nested dropdown is used; closing the panel closes anything nested inside it.
- This is the WCAG 1.4.10 answer. The bar used to wrap instead; a disclosure satisfies reflow just as well — content must be available, not simultaneously visible — and one row is what makes the stretched close safe.
Dividers travel with their neighbours and are hidden inside the panel; the panel's own separator does that job there.
Menus in the actions cluster
A sitting directly inside — the overflow trigger, a doc page's markdown menu, a tool's export options — is chrome, not a captioned control, and renders as a cell: the wrapper and trigger stretch to the bar's full height, the trigger's corners square off, and the open panel hangs off the bar's bottom border with the shared hairline drawn once. This is one stretch doing two jobs — the menu is anchored to the wrapper, so the cell's bottom edge is the menu's top edge.
The rules are keyed on the direct child on purpose: a dropdown demoted into the overflow panel is a row in a menu, not a cell in the bar, and keeps the 36px control height. A dropdown inside a in the content region also keeps it — a labelled control beside a caption is not chrome.
Breadcrumbs, tabs and page context
The identity configuration: breadcrumb in the content region, actions and close trailing. Inside a bar, a breadcrumb centres and steps up to --font-s; tabs stretch to the bar's height with the active indicator on the container's border.
<div class="bar" data-density="regular" data-width="docs" data-sticky="true">
<div class="bar-container">
<div class="bar-content">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="../website/index.html">Design System</a>
<span class="breadcrumb-separator" aria-hidden="true">/</span>
<span aria-current="page">Button</span>
</nav>
</div>
<div class="bar-actions">
<div class="dropdown">…</div>
<a href="../website/index.html" class="bar-close" data-page-close aria-label="Back to Design System">…</a>
</div>
</div>
</div>
This exact shape is what the docs generator emits on every doc page. A tool's bar is the same component with controls in the content region instead of a breadcrumb, and the same trailing cluster — actions, overflow, close (see Page Types). Toggles and segmented controls centre themselves the same way — any supported child adapts without extra classes.
Control groups
The instrument configuration. Every control in the content region resolves to one height — --bar-control-height, 36px. That is the difference between a bar and a row of unrelated widgets, and it is the component's job, not the page's. The exception is structural, not stylistic: menu cells in the actions cluster and the close stretch to the bar's full height instead — see Menus in the actions cluster.
A native select, a square colour swatch and a transparent menu trigger have nothing in common structurally, and the bar still reads as one row.
Two things the height rule has to defeat, both of which look like they should already work:
min-height: 44pxon the base input rule. The WCAG 2.5.5 AAA touch target, right for a form on a phone — but it beats any padding trim, so nothing changes until the floor is lifted withmin-height: 0.--button-padding-y, which makes a plain 54px tall. The bar re-points it to0; with a fixed height, vertical padding only fights it.
36px clears WCAG 2.5.8 Target Size (Minimum, AA), which asks for 24px. Page forms keep their 44px — dense tool chrome and a form on a phone are not the same problem.
Excluded on purpose: range, checkbox and radio size their own box and would be distorted rather than aligned; a directly inside is a full-height cell, not a 36px control. A toggle is not excluded, it is handled differently — a is a compound, a 24px switch plus its label, so it takes min-height on the wrapper: the row gets the height, the switch keeps the box it drew for itself.
Groups and labels
A is a label and its control, or a set of controls that belong together. Groups are what stop a bar reading as one long undifferentiated row — and a group is also the unit the overflow menu moves.
is a visible caption, not an accessible name. It is hidden below 768px, so the control inside the group must carry its own name regardless — and aria-label or aria-labelledby, not a bare <label for>.
That distinction is the whole trap. aria-labelledby resolves a target that is display: none; a <label for> does not. A <label class="bar-label" for="x"> therefore names its control at desktop width and leaves it anonymous below 768px, with nothing to warn you. A <label> may still wear for the sighted caption; just give the control an aria-label as well.
Where a group has more than one control, give it role="group" and an aria-label. A single labelled control does not need it.
Prefer a dropdown whose label is the answer
Screen width is the scarcest thing in a bar. A dropdown whose trigger reads the current value states the setting and its answer in one control — roughly a quarter of the width of three labelled segments offering the same choice.
Reach for a segmented control when the options are icons, when there are two of them, or when seeing all the choices at once is the point. Reach for a dropdown otherwise.
A trigger labelled with its value is not a named control. Once the trigger reads "SVG", its accessible name is that value and nothing says which setting it belongs to. Name it from the caption and the value:
<div class="bar-group">
<span class="bar-label" id="format-caption">Format</span>
<div class="dropdown">
<button class="dropdown-trigger" type="button" aria-haspopup="true"
aria-expanded="false"
aria-labelledby="format-caption format-value">
<span id="format-value">SVG</span>
…
The name resolves to "Format SVG", and survives the caption being hidden below 768px — which is exactly why the caption may be hidden at all. Do not put aria-hidden="true" on a caption referenced this way.
Read-only values belong on the canvas, not in the bar. A file size or a scale percentage is a report, not a control.
Icons instead of words
Where each option has an obvious picture — a device, an alignment, a placement — icon segments collapse a control to a fraction of its width without collapsing the choices out of sight. Icons only work when the picture is unambiguous on its own: "Draft" and "Published" have no pictures, and inventing two is worse than spending the width. Every icon segment still needs an aria-label, and a data-tooltip gives a sighted user the same word.
At the end of that line is a trigger with no caption and no value text at all — one icon, one chevron. Both halves of its name become spans joined by aria-labelledby; drop either and the control announces as an unnamed button. It costs a sighted user the label, so it needs a tooltip, and it is spent only where the bar is genuinely out of room.
Select or dropdown?
A <select> and a look near enough identical in a bar, so the choice is made on what the control returns:
| Use | Chrome | |
|---|---|---|
| A value — Format, Position, Zoom, Publisher | <select> |
Bordered |
| An action — Copy URL, Reset, Fullscreen, an overflow menu | Transparent |
This is the dropdown doc's own rule read from the bar end. A list of values dressed as a menu costs the native keyboard behaviour, the platform picker on mobile, and the correct role.
The exception is the menu itself. Reach for a on a value when the options need more than a string — a colour swatch on each row, a placement glyph, sections. Logo Tool's colour picker is the clear case; the format picker beside it is not.
Its cost, stated plainly: a <select> cannot be styled once open — the option list is the platform's. For tool chrome that is a fair price for correct semantics and a free mobile picker.
Quiet chrome
Matching the heights is only half of it. In a bar, a visible border means you can type here.
Anything that acts — a trigger, an icon button, a segment — is transparent until you point at it, and takes --background-faded on hover. Anything that accepts typing keeps a hairline border, a --radius-s corner, and re-points --input-background to the bar's own ground so a field reads as a well rather than a grey slab.
The number input proves the rule: one field with two steppers, so the border goes round the whole control and the steppers stay transparent — bordered where you type, quiet where you press.
A border means a value lives here. No border means pressing this does something.
Read that way, a <select> beside a is not an inconsistency to design away — it is the bar telling you one holds an answer and the other opens a menu. A bar where everything is boxed tells you nothing about what any of it does.
One boundary to the rule: a structural cell edge is not a control border. The close's left divider and the bar's own bottom rule mark where regions meet, not where values live — the close has always carried one without breaking the rule, and the actions-cluster cells keep to it by carrying none.
Dividers
Use the vertical divider to separate clusters that are about different things. The bar sets its own margin, so it needs no modifier.
Dividers are cheap to add and easy to overuse. A bar with a rule between every group has no grouping at all. Group first; add a divider only where two neighbours would otherwise read as one cluster. In the overflow panel a travelled divider hides itself — the panel's own separator does that job there.
Actions
holds what the bar does — export, download, copy, reset — as opposed to what it configures, plus the overflow trigger and the close. It is pushed to the end with an auto margin and never collapses, which is what makes membership of it the pinning mechanism.
Order inside it is fixed: actions, then overflow, then close.
One icon per meaning for the trailing menu. more-horizontal opens an overflow menu — every item an action or a relocated control. settings opens a settings panel of form controls, which makes it a disclosure rather than a menu. Pick by which one it is.
A cluster of quiet icons ending in one solid button is the pattern to reach for: the quiet controls are the reversible ones, the solid one is the thing the tool exists to do.
Use , not , for a button that opens nothing.
Sticky bars
A bar is static by default; data-sticky="true" docks it at --bar-top.
<div class="bar" data-density="regular" data-sticky="true">…</div>
--bar-top is 0 — right for an app shell whose bar is the top of the window. A shell with a header above it re-points the token rather than overriding top (which would mean out-specifying the component). This site's shell does exactly that for every bar, in docs-site.css §1.
A docked bar owes --scroll-offset its height, or an in-page anchor scrolls its target underneath it. The shell sums it from the bar's own tokens — --bar-height for regular density, control height plus padding for the rest — so re-pointing a knob moves the landing position with it.
Note the polarity. Site Header sticks by default and opts out; a bar is static by default and opts in. The attribute reads the same and means the opposite, so check which component you are on.
Do not stack two docked bars without deciding their order and their combined --scroll-offset deliberately: there is one --z-bar on the shared scale (§39B), and a page stacking two bars re-points it locally on the upper one.
Accessibility
- No
role="toolbar"unless you implement it. That role promises a single tab stop with arrow-key navigation between controls. This component does not ship that behaviour; claiming it leaves a keyboard user pressing arrows that do nothing. Every control is a normal tab stop. - Every icon-only control needs an accessible name and a tooltip, in the same words —
aria-labelon the control,aria-hidden="true"on the SVG,data-tooltipcarrying the same string. A speech-control user says the word they see. - A tooltip is never the name. It needs hover, so on touch it does not exist. Anything important enough to be in the bar has to survive without it.
- is not an accessible name. It disappears below 768px. Name the control itself.
- Multi-control groups get
role="group"+aria-label. - Overflow is the reflow answer. Items that do not fit move to the menu, so every control stays reachable at 400% zoom (WCAG 1.4.10 — content must be available, not simultaneously visible). Do not re-add
flex-wraporoverflowto the container. - Keep the shell scrollable. A bar inside a
100dvhshell needsoverflow-y: autoon that shell. - Segmented controls carry
aria-pressed, kept in step with , notrole="radiogroup"— the radio pattern obliges arrow-key traversal, and promising without building it is therole="toolbar"failure again. - One-of-N dropdown menus use
role="menuitemradio"witharia-checked. Keep arole="menu"container to menu items only. If the panel is really a set of switches, it is a disclosure — droprole="menu"and wirearia-expanded+aria-controls. The overflow panel holds relocated form controls, so it is a disclosure, not a menu. - The close needs an
aria-labelnaming where it goes — "Back to Tools", not "Close".
Usage rules
| Do | Don't |
|---|---|
| Compose existing components | Invent a bar-specific control |
| Group related controls with | Drop loose controls straight into the content region |
Author priority with data-priority |
Re-add flex-wrap to keep everything visible |
| Put the primary action in | Pin everything — a bar where nothing collapses just clips |
| End the bar with the close, overflow before it | Put anything after the close |
| Use a dropdown whose label is the current value | Spend a third of the bar on three labelled segments |
Use <select> for a value, for a menu |
Dress a list of options as role="menu" |
| Name every icon-only control, and tooltip it in the same words | Rely on for the name |
| Add a divider between unrelated clusters | Put a divider between every group |
| Show read-only values on the canvas | Fill the bar with readouts |
| Use for a button that opens nothing | Borrow for its look |
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | What it controls |
|---|---|---|
| Bar fill | ||
var(--border-s) solid var(--border-faded) |
The rule under the bar | |
| Space between groups, and between content and actions | ||
| Space inside a group, and between actions | ||
| Inline padding | ||
| Block padding. Both density presets zero it | ||
70px |
Fixed height of data-density="regular" only |
|
36px |
Every control in the content region resolves to this; actions-cluster menu cells and the close stretch instead | |
4.5rem |
Width of a number field in the bar | |
20rem |
Ceiling for a menu opened from the bar below 768px | |
Stacking order when data-sticky="true" |
||
0px |
Where a docked bar sits. Re-point it when a header sits above |
Selectors
| Selector | Purpose |
|---|---|
Base; background, border, flex-shrink: 0 |
|
.bar[data-sticky="true"] |
Docks at |
.bar[data-density="regular"] |
Fixed , zero block padding |
.bar[data-density="compact"] |
Zeroes ; one control tall |
| The row. Drops inline-end padding when a is present | |
| Leading region; no clip, no scroll — menus open across its edge, a too-wide item truncates itself | |
| A labelled cluster; the unit overflow moves | |
| Caption text; hidden below 768px, shown again inside the overflow panel | |
| Trailing cluster, auto inline-start margin; never collapses | |
.bar-actions > .dropdown |
Full-height menu cell: wrapper and trigger stretch, weld margins, square corners, panel hangs off the bar's border with the shared hairline drawn once |
| The "more" dropdown; hidden while empty. The panel takes a gutter and restates group layout; a dropdown demoted into it spreads to the row's full width and opens statically, as a disclosure | |
| Terminal cell: full-height stretch, left divider, weld margins | |
.bar .button, , select, input, , |
One height, and min-height: 0 to lift the base input's 44px floor |
.bar .button[data-icon-only], .bar input[type="color"] |
Square at the control height |
.bar .number-input input[type="number"] |
Fixed width and tabular figures so a long value can't resize the bar |
.bar .divider--vertical |
Bar-scale margin instead of page-scale |
.bar .form-toggle |
min-height at the control height |
.bar .tabs / .bar .tab |
Stretch to bar height; indicator on the container's border |
.bar .breadcrumb |
Centres, steps up to ; truncation is the docs shell's |
Key rules
min-height: 0 on the control list is the rule that makes the heights work — the base input's 44px floor beats padding, so trimming padding alone changes nothing.
One row, enforced by the overflow model rather than by CSS alone: bar.js reads the content region's scrollWidth overhang and moves what does not fit into the menu — no clipping involved, which is what lets a menu open from inside the region at all. The old toolbar wrapped instead; the wrap came out because a wrapping bar makes a stretched close a tall slab and its --scroll-offset sum a lie.
uses margin-inline-start: auto, not justify-content: space-between — with a single child, space-between resolves to flex-start. The site header makes the same move.
Density comes from re-pointing each component's own custom properties rather than overriding its rules. Real declarations appear only for native select and input, which expose no properties to re-point.
The 768px block hides , re-points --bar-gap and --bar-padding-x (never literals, so a brand's re-point survives every width), and clamps --dropdown-menu-min-width so a panel fits a 375px screen. Hiding the caption is only safe because every control carries its own accessible name — see Groups and labels.
neutralises the base label element's display: block, weight and bottom margin, because a caption is allowed to be a real <label for> — and in an align-items: center row it is the margin box that gets centred.
Related
Site Header · Button · Dropdown · Form · Divider · Breadcrumb · Tabs
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. Its behaviour ships as dist/js/bar.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/bar.js" defer></script>