The site header is the bar at the top of a page: identity on one side, controls on the other, and an optional Nav between them. It is sticky by default and knows nothing about the page beneath it — placement is the consumer's job, so the same component works in a CSS grid shell, a marketing page, or a tool app.
It is not the Bar. The two share a shape, but a header carries site identity and global navigation while a bar carries what belongs to the page you are on — and the bar sits below the header rather than beside it.
Anatomy
header.site-header data-sticky="false" | data-layout="center"
a.skip-link first in the DOM
.site-header-inner max-width, centring, padding
.site-header-start optional
button.button.header-action menu, back, anything leading
a.site-header-logo
nav.nav optional
.site-header-end optional
.dropdown account, mode switch
button.button.header-action theme toggle
Two things about this shape are deliberate.
The inner element is not decoration. The outer element is full-bleed and owns background, border and sticky; the inner owns width and padding. That split is what lets the bottom border run edge to edge while the contents line up with a content column.
Slots are optional and never empty. The end slot is pushed over with margin-inline-start: auto, not justify-content: space-between. With a single child, space-between resolves to flex-start — a header carrying only an end slot would render on the left. The auto margin gets every combination right, so you never emit an empty <div> to hold the shape.
Basic usage
<header class="site-header">
<a class="skip-link" href="#main">Skip to content</a>
<div class="site-header-inner">
<div class="site-header-start">
<a class="site-header-logo" href="/">Acme</a>
</div>
<div class="site-header-end">
<button type="button" class="button header-action">Sign in</button>
</div>
</div>
</header>
With navigation
Slots are optional
These two demos are the reason the component uses an auto margin rather than space-between. Neither emits a placeholder for the slot it does not have.
Start only — sits left, as you would expect.
End only — sits right. Under space-between this would have rendered on the left.
Variants
Centred
data-layout="center" switches the inner element to grid-template-columns: 1fr auto 1fr. Flex with an auto margin cannot centre optically — the middle would drift with whatever the sides weigh — so this is a mode, not a slot.
Sticky, and opting out
Sticky is the default. Scroll the frame below — the header stays, and data-sticky="false" does not.
Scroll this frame. The header above stays pinned to the top of it.
A sticky element only sticks within its own scroll container, which is why this demo needs a frame at all.
Keep scrolling.
Still here.
And the static header below behaves differently.
The static header scrolled away with the content.
More content.
End of frame.
<header class="site-header" data-sticky="false">…</header>
Composition
Everything in the end slot is an existing component. The header adds no new controls.
Account dropdown
Mode switch
A one-of-N set uses role="menuitemradio" with aria-checked, so the chosen mode is announced and not merely highlighted.
<div class="dropdown" data-placement="bottom-end">
<button class="dropdown-trigger" type="button" aria-haspopup="true" aria-expanded="false">Theme</button>
<div class="dropdown-menu" role="menu">
<div role="group">
<button class="dropdown-item" role="menuitemradio" aria-checked="true" data-value="light">Light</button>
<button class="dropdown-item" role="menuitemradio" aria-checked="false" data-value="dark">Dark</button>
<button class="dropdown-item" role="menuitemradio" aria-checked="false" data-value="system">System</button>
</div>
</div>
</div>
Menu button opening a drawer
The header's menu button carries data-drawer-open. Dialog's script supplies opening, Escape, the focus trap, focus return and scroll lock — there is no bespoke open/close code behind it.
<button type="button" class="button header-action" data-icon-only
data-drawer-open="main-nav" aria-controls="main-nav"
aria-expanded="false" aria-label="Open navigation">
<div class="svg-icn" data-icon="menu"></div>
</button>
aria-expanded is the button's own state and the drawer does not own it. Drive it from the dialog's close event rather than from the click, or the two drift apart the first time something else closes the drawer.
With a bar beneath
A page sub-header docks below the header at top: var(--top-nav-height), and the header's z-index: 100 sits above the bar's 40. See Bar.
Responsive
Below 768px the header changes in three ways, none of which needs authoring:
| What | Behaviour |
|---|---|
| Action labels | is hidden; the accessible name stays on aria-label |
| Nav | Hidden only with data-nav-collapse="drawer" — see below |
| Padding | Contents go flush to the viewport edge, so the first and last actions read as full-height tap targets |
The frame below is a real 390px viewport, not a narrow box. Media queries resolve against an iframe's own viewport, so the header's responsive rules genuinely fire inside it; a narrow <div> on this page could not show them at all.
Collapsing the nav is opt-in
data-nav-collapse="drawer" is what hides the nav at this width. It is not automatic, because a product with four header links and no drawer would otherwise lose its entire navigation on small screens with a class override as the only way back.
<header class="site-header" data-nav-collapse="drawer">
Set it once you have somewhere for those links to go.
Which side the menu button goes on
The leading edge, matching the drawer's data-placement="start". The button and the panel should share an edge — put the button on the right while the drawer still slides in from the left and the panel appears to come from the opposite side to the control that summoned it, which reads as a glitch rather than a choice.
If thumb reach matters more than convention on a given product, move both: the button to and the drawer to data-placement="end". Moving one without the other is the mistake.
Accessibility
| Requirement | How |
|---|---|
| Landmark | <header> is the element; it is a banner landmark at the top level |
| Skip link | First in the DOM, revealed on focus, targeting the page's <main> |
| Menu button | aria-expanded, aria-controls, and an aria-label that changes with state |
| Icon-only actions | aria-label on every one; the visible label is decorative and may be hidden |
| Touch targets | .button.header-action carries min-block-size: 44px |
| Focus | Inherited from the global focus rule |
The skip link's collapsed box is load-bearing. It is hidden with a 1px box, overflow: hidden, pointer-events: none and clip-path — not display: none or visibility: hidden, which would take it out of the focus order and defeat the point. Clipping alone is not enough: a clipped element keeps its full layout box and still receives pointer events, so a padded, clipped skip link sits invisibly over the header and swallows clicks meant for the controls beneath it. The padding therefore lives in the focused state.
Usage rules
Do:
- Put the skip link first in the DOM, before anything else in the header
- Give the page's
<main>theidthe skip link targets - Put dropdowns in
- Pair a header nav with a drawer, since the nav is hidden on small screens
- Use
.button.header-actionfor controls, so they inherit the button API
Don't:
- Don't emit an empty slot to hold the layout — the auto margin handles absent slots
- Don't put a dropdown inside a clipping ancestor — any
overflowother thanvisiblecuts the menu off silently. ( was exactly this until it cost two tools their menus; it no longer clips, but the hazard stands anywhere you introduceoverflow) - Don't write a bare selector. The button base also matches as
button.button(0,1,1), so an unqualified class loses; write.button.header-action - Don't assume tabs in a header re-bind themselves on a client-side page swap.
tabs.jsre-inits onbd:after-nav, so a router that dispatches that event is covered; one that does not leaves the tabs bound to markup that has been replaced - Don't set
grid-areaor any page-layout property on from the component side; placement belongs to the consumer
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | What it controls |
|---|---|---|
80px |
Bar height; also feeds | |
none |
Content width. Full-bleed by default; set a length to align with a content column | |
| Inset on the inner element | ||
| Space between slots | ||
| Bar background | ||
| Bar foreground, inherited by actions | ||
var(--border-s) solid var(--border-faded) |
Bottom border | |
100 |
Stacking; sits above the bar's 40 |
Selectors
| Selector | Purpose |
|---|---|
| Base component, sticky | |
.site-header[data-sticky="false"] |
Static variant |
.site-header[data-layout="center"] .site-header-inner |
Grid mode for true centring |
| Flex row; width, centring, padding | |
| / | Slots; end carries the auto margin |
| Identity link | |
.svg-logo.site-header-logo-image |
Logo sizing inside that link |
.button.header-action |
Button role class for bar controls |
| Keyboard skip target |
Key rules
Slot placement: margin-inline-start: auto on , never space-between — see Anatomy.
Logical properties throughout (inset-inline, margin-inline), so RTL works without a mirrored stylesheet..button.header-action re-points --button-* only, plus align-self: stretch, border-radius: 0 and the 44px minimum. Its :hover rule is required rather than cosmetic: .button:hover and .button.header-action both re-point --button-color and the role class is later in the file, so without an explicit hover it would win during hover and the control would never change colour.
Related
--scroll-offset (design-system.css §39B) is the total height of whatever is docked to the top of the viewport. A shell that has a header and a docked bar declares it as the sum of both, and in-page anchors then clear them. Raise --site-header-height and anchor landings move with it.
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. No JavaScript, nothing else to include.