Skip to content

Component

app-bar

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and app-bar.css. No scripts.

Source

Copy both files, or run npx nojsui add app-bar.

Source for app-bar
<!-- <header>, not <div>: this is the banner landmark and a div costs you that
     for nothing. The <nav> carries an accessible name because a page usually
     has more than one navigation landmark.

     Scroll the frame to watch the bar tighten. That is a scroll-progress
     timeline, not a listener — and in an engine without scroll-driven
     animations, or under reduced motion, the bar simply stays at its resting
     size, which is a normal header.

     The action buttons are unstyled: a demo loads exactly one component's CSS,
     so .sk-button is not available here. On a real page you would use both. -->
<header class="sk-app-bar" data-position="sticky" data-surface="translucent" data-condense>
  <a class="sk-app-bar__brand" href="https://nojsui.com/">Studio</a>

  <nav class="sk-app-bar__nav" aria-label="Primary">
    <ul class="sk-app-bar__list">
      <li><a class="sk-app-bar__link" href="https://nojsui.com/components/" aria-current="page">Work</a></li>
      <li><a class="sk-app-bar__link" href="https://nojsui.com/components/">Studio</a></li>
      <li><a class="sk-app-bar__link" href="https://nojsui.com/components/">Journal</a></li>
    </ul>
  </nav>

  <div class="sk-app-bar__actions">
    <button type="button">Contact</button>
  </div>
</header>

<main class="sk-app-bar__page">
  <p class="sk-app-bar__filler">Scroll ↓ — the bar tightens over the first 8rem.</p>
  <p class="sk-app-bar__filler">
    The height and the shadow are one scroll-progress animation. Nothing
    listens to a scroll event; the browser is already tracking the position
    and the animation reads it.
  </p>
  <p class="sk-app-bar__filler">
    Without scroll-driven animations, and under reduced motion, the bar keeps
    its resting size. The resting state is the expanded one, so the fallback is
    an ordinary header rather than a permanently collapsed one.
  </p>
  <p class="sk-app-bar__filler">
    The translucent surface reverts to an opaque background under
    <code>prefers-reduced-transparency: reduce</code>. That fallback is part of
    the component.
  </p>
  <p class="sk-app-bar__filler">More space, so there is something to scroll.</p>
  <p class="sk-app-bar__filler">Still scrolling.</p>
  <p class="sk-app-bar__filler">Nearly there.</p>
  <p class="sk-app-bar__filler">The end of the demo page.</p>
</main>
/* .sk-app-bar — site chrome that condenses as the page moves.

   THE TWO THINGS THIS COMPONENT EXISTS TO CARRY:

   1. The prefers-reduced-transparency fallback for the translucent surface.
      A hand-rolled translucent header almost never has one, and a user who
      asked their OS for less transparency asked for a reason.

   2. Condense-on-scroll as a scroll-progress timeline instead of a listener.
      The browser already tracks scroll position; the animation reads it.

   THE TRAP, same as reveal.css: longhands, never the `animation` shorthand.
   The shorthand resets animation-duration to 0s, and a scroll-driven animation
   with zero duration finishes instantly — the bar would render permanently
   condensed. `auto` is what hands the timing to the timeline.

   AND THE RULE: the resting state is the EXPANDED one. The keyframes hold only
   a `to`, so an engine without scroll timelines renders a normal header rather
   than a collapsed one.

   Specs:
   - animation-timeline ..... https://drafts.csswg.org/scroll-animations-1/#animation-timeline
   - scroll() ............... https://drafts.csswg.org/scroll-animations-1/#scroll-notation
   - backdrop-filter ........ https://drafts.fxtf.org/filter-effects-2/#BackdropFilterProperty
   - prefers-reduced-transparency
                              https://drafts.csswg.org/mediaqueries-5/#prefers-reduced-transparency */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-app-bar {
  --_bg: var(--sk-app-bar-bg, var(--sk-color-surface));
  --_fg: var(--sk-app-bar-fg, var(--sk-color-text));
  --_link-fg: var(--sk-app-bar-link-fg, var(--sk-color-text-muted));
  --_border-color: var(--sk-app-bar-border-color, var(--sk-color-border));
  --_block-size: var(--sk-app-bar-block-size, 4rem);
  --_block-size-condensed: var(--sk-app-bar-block-size-condensed, 3rem);
  --_blur: var(--sk-app-bar-blur, 12px);
  --_z: var(--sk-app-bar-z, 100);

  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: var(--sk-space-md);
  align-items: center;
  min-block-size: var(--_block-size);
  padding-inline: var(--sk-space-md);
  border-block-end: var(--sk-border-width) solid var(--_border-color);
  background: var(--_bg);
  color: var(--_fg);
  z-index: var(--_z);
}

/* --- position ----------------------------------------------------------- */

.sk-app-bar[data-position="sticky"] {
  position: sticky;
  inset-block-start: 0;
}

.sk-app-bar[data-position="fixed"] {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
}

/* --- translucency ------------------------------------------------------- */

@supports (backdrop-filter: blur(1px)) {
  .sk-app-bar[data-surface="translucent"] {
    background: color-mix(in oklab, var(--_bg) 72%, transparent);
    backdrop-filter: blur(var(--_blur)) saturate(180%);
  }

  /* The part teams forget. Someone who asked their OS to reduce transparency
     did so because layered translucent surfaces are hard for them to read;
     handing them a blurred header anyway is not a small thing. */
  @media (prefers-reduced-transparency: reduce) {
    .sk-app-bar[data-surface="translucent"] {
      background: var(--_bg);
      backdrop-filter: none;
    }
  }
}

/* --- condense ----------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .sk-app-bar[data-condense] {
      animation-name: sk-app-bar-condense;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: scroll(root block);
      animation-range: 0 8rem;
    }

    /* Only a `to`. The implicit `from` is the bar's resting size, which is
       exactly the state an engine without this feature keeps. */
    @keyframes sk-app-bar-condense {
      to {
        min-block-size: var(--_block-size-condensed);
        box-shadow: var(--sk-shadow-sm);
      }
    }
  }
}

/* --- slots -------------------------------------------------------------- */

.sk-app-bar__brand {
  color: inherit;
  font-weight: var(--sk-weight-semibold);
  letter-spacing: var(--sk-tracking-tight);
  text-decoration: none;
}

.sk-app-bar__nav {
  justify-self: center;
}

.sk-app-bar__list {
  display: flex;
  gap: var(--sk-space-md);
}

.sk-app-bar__link {
  color: var(--_link-fg);
  font-size: var(--sk-text-sm);
  text-decoration: none;
}

.sk-app-bar__link:hover,
.sk-app-bar__link[aria-current="page"] {
  color: var(--_fg);
}

.sk-app-bar__actions {
  display: flex;
  gap: var(--sk-space-xs);
  justify-self: end;
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The demo needs something to scroll past for the condense range to mean
   anything. Delete this when copying .sk-app-bar.
--------------------------------------------------------------------------- */

.sk-app-bar__page {
  display: grid;
  gap: var(--sk-space-md);
  padding-block: var(--sk-space-lg) var(--sk-space-3xl);
  padding-inline: var(--sk-space-md);
}

.sk-app-bar__filler {
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
backdrop-filternewly7679103187610318
prefers-reduced-transparency media querylimited119119119
Scroll-driven animationslimited1151152611526

Usage

<header class="sk-app-bar" data-position="sticky" data-surface="translucent" data-condense>
  <a class="sk-app-bar__brand" href="/">Studio</a>
  <nav class="sk-app-bar__nav" aria-label="Primary">
    <ul class="sk-app-bar__list">
      <li><a class="sk-app-bar__link" href="/work/">Work</a></li>
      <li><a class="sk-app-bar__link" href="/about/">About</a></li>
    </ul>
  </nav>
  <div class="sk-app-bar__actions">
    <button type="button">Contact</button>
  </div>
</header>

Three slots on a three-column grid: __brand, __nav, __actions. Any slot may be omitted — the grid keeps its columns, so the brand stays put whether or not there are actions beside it.

AttributeEffect
data-position="static" (or omitted)In normal flow
data-position="sticky"Sticks to the block start of its scroll container
data-position="fixed"Pinned to the viewport, out of flow
data-surface="translucent"Blurred, saturated backdrop — with the reduced-transparency fallback built in
data-condenseTightens as the page scrolls, on a scroll timeline

Condense-on-scroll is a timeline, not a listener

The behaviour every site writes JavaScript for — a header that shrinks once you have scrolled a little — is a scroll-progress animation. The browser already tracks scroll position; the animation just reads it:

animation-timeline: scroll(root block);
animation-range: 0 8rem;

No listener, no requestAnimationFrame, no class toggling, and no layout read on every frame.

Two things about this are easy to get wrong, and both are load-bearing here.

Longhands, never the animation shorthand. The shorthand resets animation-duration to 0s, and a scroll-driven animation with zero duration finishes instantly — the bar would render permanently condensed and the timeline would appear to do nothing. animation-duration: auto is what hands the timing to the timeline. This is the same trap documented on reveal.

The resting state is the expanded one. The @keyframes block contains only a to, so the “from” is the bar’s ordinary expanded size, and every condensing declaration lives inside both @supports (animation-timeline: scroll()) and the reduced-motion guard. An engine without scroll timelines therefore renders a normal header — not a permanently collapsed one, which is what writing it the other way around produces.

Translucency ships with its own fallback

data-surface="translucent" blurs and saturates what is behind the bar. That is a problem for people who have asked their OS to reduce transparency — usually because layered translucent surfaces are hard for them to read.

@media (prefers-reduced-transparency: reduce) {
  .sk-app-bar[data-surface="translucent"] {
    background: var(--_bg);
    backdrop-filter: none;
  }
}

The opaque path is part of the component, not an exercise for the reader. This is the detail the component exists to carry: a hand-rolled translucent header almost never has it.

Theming

PropertyDefaultControls
--sk-app-bar-bg--sk-color-surfaceBar background
--sk-app-bar-fg--sk-color-textBrand and active link colour
--sk-app-bar-link-fg--sk-color-text-mutedResting nav link colour
--sk-app-bar-border-color--sk-color-borderThe bottom edge
--sk-app-bar-block-size4remResting height
--sk-app-bar-block-size-condensed3remHeight at the end of the condense range
--sk-app-bar-blur12pxBackdrop blur radius when translucent
--sk-app-bar-z100Stacking order. A skip link must sit above this

Keyboard contract

Nothing custom. The brand and the nav links are native anchors and the actions are native controls, so Tab moves through them in source order: brand, then each nav link, then each action. The ring comes from base.css.

The <nav> needs an accessible name — aria-label="Primary" in the example above — because a page usually has more than one navigation landmark and “navigation, navigation” is not a useful thing to hear.

Verified manually in VoiceOver and NVDA: not yet — do this before marking the component done. What to check: that the brand, each nav link and each action are reached in source order, and that the <nav> is announced by its accessible name rather than as a bare navigation landmark.

Accessibility notes

  • Use <header> for the element. It is the banner landmark, and a <div> here costs you that for nothing.
  • The condense animation moves the bar’s own size only. It never hides a link, and it never removes anything from the accessibility tree.
  • With data-position="fixed", remember the bar covers content at the top of the page. base.css sets scroll-padding-block-start on :root so anchor targets clear sticky chrome; increase it if your bar is taller than --sk-space-xl.
  • A skip link is not the only thing that can go behind the bar. tooltip’s bubble is a plain absolutely-positioned element at z-index: 1 that opens upward, so a tooltip near the top of the page can end up behind a sticky/fixed app bar too. Components that render in the top layer — dialog, popover, toast, command-menu — are unaffected.
  • All condensing is inside @media (prefers-reduced-motion: no-preference).

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
Scroll-driven animationslimitedThe bar stays at its resting size. A normal header — not a collapsed one, because the resting state is the expanded one.
backdrop-filternewlyThe translucent surface renders as the opaque --sk-app-bar-bg instead. Identical to what a reduced-transparency user gets.
prefers-reduced-transparencylimitedThe query never matches, so a user who asked for less transparency keeps the blur. Chromium-only today; there is no CSS-side way to do better.

Demo note

Component demos load tokens.css, base.css and one component’s CSS and nothing else, so the buttons in this demo’s __actions are unstyled native buttons rather than .sk-button. On a real page you would use both.