Nav — HTML & CSS
A navigation bar that becomes a burger-driven drawer on narrow screens.
Above 576px it's a plain flex row. Below it, the panel slides out from the side as a modal drawer — the page behind goes inert, the body stops scrolling, Escape closes, and Tab is trapped.
The bar itself never disappears. A logo, a cart, an avatar can stay on it at every width, drawer open or closed.
Creating the manager, the events, and
open()/close()are on the JavaScript page.
Live example
The demo runs in an <iframe>, and that isn't a detail — the drawer is position: fixed and modal, so on a normal page it takes the page over: freezes its scroll, inerts everything around it, holds the focus. An iframe is a viewport of its own, so all of that lands inside the frame, where it's the demo instead of the problem. It also means the width buttons are real: the same node is a bar at 900px and a drawer at 414px, no resizing of your browser required.
Scroll the frame down and the bar slides away — that's .UInav--auto-hide, below.
Markup
<nav class="UInav UInav--sticky" aria-label="Main">
<a class="UInav__logo" href="/">◈ Acme</a>
<div class="UInav__panel">
<ul class="UInav__start">
<li><a class="UInav__link" href="/docs" aria-current="page">Docs</a></li>
<li><a class="UInav__link" href="/pricing">Pricing</a></li>
</ul>
<div class="UInav__end">
<a class="UInav__link" href="/login">Log in</a>
<button class="UIb primary" type="button" data-value="signup">Sign up</button>
</div>
</div>
<div class="UInav__pinned">
<button class="UIb" type="button" data-value="cart">
<span class="UIb__icon">🛒</span>
<span class="UIb__label ui-mobile-hide">Cart</span>
</button>
</div>
<button class="UInav__toggle" type="button">
<span class="UInav__burger"></span>
</button>
</nav>The backdrop, the toggle's aria-expanded / aria-controls / aria-label, and the panel's id are all written for you by the manager.
The one rule
.UInav__panel is the drawer. It isn't a desktop layout that gets copied into a mobile menu — below the breakpoint that same element turns position: fixed and slides in. Nothing is cloned, no node is moved, so listeners, focus and component state survive a resize.
Which means: CSS can't lift a child out of a fixed container. Anything that must stay on the bar while the drawer is open has to live outside the panel — that's .UInav__pinned. Pinned is a place in the DOM, not a class.
Where each slot lands
| Slot | Wide (bar) | Narrow (drawer) |
|---|---|---|
__logo | far left | stays on the bar |
__start | left, after the logo | top of the drawer |
__end | pushed right | bottom of the drawer |
__pinned | right, before the toggle | stays on the bar |
__toggle | hidden | burger → cross |
Knobs
Custom properties on .UInav:
| Property | Default | |
|---|---|---|
--n-drawer-w | 90% | drawer width — try min(90%, 22rem) so it doesn't sprawl on a tablet |
--n-backdrop | rgb(0 0 0 / 45%) | the scrim |
--n-h | 3.5rem | bar height |
--n-z | 900 | above the page, below Toast's 9999 |
--n-speed | 0.25s | slide/fade |
--n-50 … --n-950 | grey | the usual shade ladder |
.UInav--sticky pins the bar to the top of the viewport. Without it the bar scrolls away with the page; the drawer still meets it correctly either way.
Auto-hide
A sticky bar buys permanent reach with permanent screen — on a phone it sits on top of every paragraph the reader came for. .UInav--auto-hide gives the screen back without giving up the reach: the bar slides its own height out of view while the reader goes down the page, and comes back on the first scroll up, wherever they are.
<nav class="UInav UInav--sticky UInav--auto-hide" aria-label="Main">It layers on top of --sticky and needs it — a bar that scrolls away anyway has nothing to hide. Only a transform moves, so nothing below the bar reflows and the drawer still hangs off its real position. The manager owns the .UInav--hidden class; don't write it by hand.
Three rules are baked in, and they're the difference between this and a bar that twitches:
- 6px threshold — momentum, rubber-band and a focus jump aren't a decision to turn around. Slow deliberate scrolling still accumulates into one.
- Nothing at the top of the page — until the page has scrolled past the bar's own height, the bar is still in its own place and has nothing to hide.
- The bar never leaves while the drawer is open — the drawer hangs off it, and the button that closes the drawer is on it.
prefers-reduced-motion: reduce drops the slide; the bar still appears and disappears.
The breakpoint is compile-time
$bp-nav is 576px — 768px leaves a bar with room to spare (a burger there is friction), and 480px is already too tight for a logo plus pinned controls plus a nav row.
A media query can't read a CSS custom property, so on the prebuilt dist/style.css you can't retune it. Build the SCSS to change it:
@use "@popovandrii/ui-elements/src/scss/var" with ($bp-nav: 640px);Other components in the bar
The slots take components, not just links. Every component here is 2.5rem tall, so the bar row lines up on its own — and the drawer needs nothing from you either: the nav puts the two components that carry a stand-alone width (.UIselect at 12rem, .UIsp at 13rem) on the slot's width below the breakpoint only, and leaves them their own above it.
<div class="UInav__panel">
<ul class="UInav__start">
<li>
<div class="UIsp sm" data-min="1" data-max="9" role="spinbutton" tabindex="0" aria-label="Quantity">
<button class="UIsp__btn" type="button" aria-label="Decrease">–</button>
<input class="UIsp__input" type="text" value="1" inputmode="numeric" aria-label="Quantity">
<button class="UIsp__btn" type="button" aria-label="Increase">+</button>
</div>
</li>
<li>
<div class="UIbg xsm" role="radiogroup" aria-label="View">
<input type="radio" class="UIbg-input" name="view" id="v-grid" value="grid" checked>
<label class="UIbg-btn active" for="v-grid">Grid</label>
<input type="radio" class="UIbg-input" name="view" id="v-list" value="list">
<label class="UIbg-btn" for="v-list">List</label>
</div>
</li>
<li><a class="UInav__link" href="/docs" aria-current="page">Docs</a></li>
</ul>
<div class="UInav__end">
<div class="UIselect sm" tabindex="0" role="listbox" aria-label="Language">
<input type="hidden" name="lang">
<span class="UIselect-selected"><span>English</span></span>
<ul class="UIselect-options" hidden>
<li class="UIselect-options__items" role="presentation">
<ul>
<li role="option" data-value="en" aria-selected="true">English</li>
<li role="option" data-value="de">Deutsch</li>
</ul>
</li>
</ul>
</div>
<button class="UIb primary" type="button" data-value="signin">Sign in</button>
</div>
</div>
<!-- Outside the panel, so it stays reachable with the drawer open. -->
<div class="UInav__pinned">
<div class="UIsw xsm" role="switch" tabindex="0">
<label class="UIsw-label ui-mobile-hide" for="theme">Dark</label>
<input type="checkbox" id="theme" hidden>
<span class="UIsw-slider"></span>
</div>
</div>Each component still needs its own manager (new Switch(), new Select(), …). If other instances elsewhere on the page get destroyed and re-initialised, scope the bar's to it so they aren't taken along:
const bar = document.querySelector<HTMLElement>("#site-nav")!;
new Select({}, false, { root: bar });A component's own palette wins in the slots — .UIb primary, .UIsw success. They don't inherit --n-*, and shouldn't: an accent control that took the bar's colours would dissolve into it.
Select works in the drawer
The option list is sized to its host and flips upward on its own near the drawer's floor, so in __end it opens up into the drawer rather than down through it. It can't escape the panel, though — the drawer is overflow-y: auto, so a list longer than the room above it scrolls with the drawer. Keep drawer selects short.