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
Keep role="list" on the nav’s <ul>. It looks redundant and is not: WebKit
can drop the list role from a list whose markers are hidden (WebKit bug
170179), and VoiceOver then announces neither the list nor how many items it
has. The attribute keeps both, and costs nothing in other engines.
- 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.