⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

Site Header

Identity, navigation and controls at the top of a page

Website / Site Header
Download .md file
Open .md in new tab
On this page
  • Anatomy
  • Basic usage
  • Slots are optional
  • Variants
  • Composition
  • Responsive
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product

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

Acme
<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

Acme
Work Studio Journal

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.

Acme

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.

Acme

Sticky, and opting out

Sticky is the default. Scroll the frame below — the header stays, and data-sticky="false" does not.

Sticky

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.

Static

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

Acme

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> the id the skip link targets
  • Put dropdowns in
  • Pair a header nav with a drawer, since the nav is hidden on small screens
  • Use .button.header-action for 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 overflow other than visible cuts the menu off silently. ( was exactly this until it cost two tools their menus; it no longer clips, but the hazard stands anywhere you introduce overflow)
  • 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.js re-inits on bd: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-area or 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.

On this page
  • Anatomy
  • Basic usage
  • Slots are optional
  • Variants
  • Composition
  • Responsive
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product
Previous Nav
Next Footer

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default