Component
app-bar
Demo
tokens.css,
base.css and app-bar.css. No scripts.
Source
Copy both files, or run npx nojsui add app-bar.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| backdrop-filter | newly | 76 | 79 | 103 | 18 | 76 | 103 | 18 |
| prefers-reduced-transparency media query | limited | 119 | 119 | — | — | 119 | — | — |
| Scroll-driven animations | limited | 115 | 115 | — | 26 | 115 | — | 26 |
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.
| Attribute | Effect |
|---|---|
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-condense | Tightens 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
| Property | Default | Controls |
|---|---|---|
--sk-app-bar-bg | --sk-color-surface | Bar background |
--sk-app-bar-fg | --sk-color-text | Brand and active link colour |
--sk-app-bar-link-fg | --sk-color-text-muted | Resting nav link colour |
--sk-app-bar-border-color | --sk-color-border | The bottom edge |
--sk-app-bar-block-size | 4rem | Resting height |
--sk-app-bar-block-size-condensed | 3rem | Height at the end of the condense range |
--sk-app-bar-blur | 12px | Backdrop blur radius when translucent |
--sk-app-bar-z | 100 | Stacking 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.csssetsscroll-padding-block-starton:rootso 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 atz-index: 1that opens upward, so a tooltip near the top of the page can end up behind asticky/fixedapp 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 used | Baseline status | Behavior without it |
|---|---|---|
| Scroll-driven animations | limited | The bar stays at its resting size. A normal header — not a collapsed one, because the resting state is the expanded one. |
backdrop-filter | newly | The translucent surface renders as the opaque --sk-app-bar-bg instead. Identical to what a reduced-transparency user gets. |
prefers-reduced-transparency | limited | The 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.