Sticky header
Install and use
Section titled “Install and use”import "surface-elements/header"<surface-header> <header> <a href="/">Store</a> <button commandfor="cart" command="show-modal">Cart</button> </header></surface-header>Wrap your page <header> in <surface-header>. The <header> stays native, so it keeps its banner landmark. The element sticks to the top of its scroll container and adds its styles to the document, so you do not import the CSS.
Scroll this frame down: the header hides. Scroll up: it comes back, with a shadow while it is stuck.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore.
Ut enim ad minim veniam, quis nostrud exercitation.
Duis aute irure dolor in reprehenderit in voluptate.
Excepteur sint occaecat cupidatat non proident.
Sunt in culpa qui officia deserunt mollit anim.
Curabitur pretium tincidunt lacus, nulla gravida orci.
Integer vitae libero ac risus egestas placerat.
Hide on scroll
Section titled “Hide on scroll”Add hide-on-scroll to move the header out of view while the reader scrolls down. It comes back as soon as they scroll up, and whenever focus moves inside it, so keyboard users never tab into a hidden header.
<surface-header hide-on-scroll>…</surface-header>It never hides what the reader is using: focus inside, an open <details> or an open popover inside keep it visible.
Keep it visible from outside
Section titled “Keep it visible from outside”For a menu that lives outside the header, call hold(). It returns a release function; the header stays visible until every hold is released.
const header = document.querySelector("surface-header")let release
menu.addEventListener("toggle", (event) => { if (event.newState === "open") release = header.hold() else release?.()})Hide over a section
Section titled “Hide over a section”Mark a section with data-surface-header="hidden" and the header hides while that section is under it, whichever way the reader scrolls. It works with or without hide-on-scroll, and it still shows when focus moves inside the header.
<section data-surface-header="hidden" style="min-block-size: 100vh">…</section>Transparent over the hero
Section titled “Transparent over the hero”Add overlay and the header sits on top of the content below it, see-through, until it sticks. Once stuck, and every time it comes back on scroll up, it takes the solid background.
<surface-header overlay hide-on-scroll>…</surface-header><div class="hero">…</div>surface-header { --surface-header-background: white; --surface-header-color: black; --surface-header-top-background: transparent; --surface-header-top-color: white;}The header does not copy the hero’s color: a transparent background shows whatever is behind it, images and gradients included. Give the hero enough padding at the top for the header, for example padding-block-start: var(--surface-header-height).
The element measures its height with JavaScript, so on first paint the variable does not exist yet and the hero would jump up once it loads. Give it a fallback per breakpoint that matches your header:
surface-header { margin-block-end: calc(-1 * var(--surface-header-height, 50px));}
@media (width >= 64rem) { surface-header { margin-block-end: calc(-1 * var(--surface-header-height, 120px)); }}After that, every size change (breakpoints, rotation, a header that wraps) is measured live: hiding, the overlay and --surface-header-height follow the current height.
Style the stuck state
Section titled “Style the stuck state”:state(stuck) matches while the header is stuck. Style the header inside it, for example to add a shadow or make it smaller.
surface-header:state(stuck) header { box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 0.1);}If you change its size when stuck, set overflow-anchor: none on the element’s parent. Without it, scroll anchoring compensates for the size change, pushes the header out of its stuck position, and it flickers.
Keep anchors visible
Section titled “Keep anchors visible”The element publishes its height as --surface-header-height on its scroll container (<html> for the page). Use it so links to #anchors do not land under the header:
html { scroll-padding-block-start: var(--surface-header-height, 0px);}The whole web component in one file, with its styles and dependencies inlined. Save it as surface-header.js in your project and load it as a module. Nothing to install, and no updates unless you copy it again: see Use without installing.
<script type="module" src="/surface-header.js"></script>surface-header.js (8.7 kB)
/*! <surface-header> from surface-elements, https://github.com/anuarhdz/surface-elements * MIT License, Anuar Reyes <anuar@hey.com> * * Copy file: the whole web component in one module, with nothing to * install. Load it with <script type="module" src="…"></script>. * Generated by `pnpm build` from src/; do not edit it in the repo. *///#region src/utils/define.tsfunction defineElement(tagName, constructor) { if (customElements.get(tagName) === void 0) customElements.define(tagName, constructor);}//#endregion//#region src/header/styles.ts/*** Header styles for `surface-header`. Light DOM, wrapped in the* `surface-elements` cascade layer so any unlayered consumer rule wins.** Without the script the header still sticks, and its descendants can query* `@container surface-header scroll-state(stuck: top)` where supported.*/const headerStyles = `@layer surface-elements { surface-header { --_offset: var(--surface-header-offset, 0px);
display: block; position: sticky; inset-block-start: var(--_offset); z-index: 1; background: var(--surface-header-background, Canvas); color: var(--surface-header-color, CanvasText); container: surface-header / scroll-state;
transition-property: translate, background-color, color; transition-duration: var(--surface-header-duration, 0.2s); transition-timing-function: var(--surface-header-easing, ease-out); }
/* Overlays the content below by its own height, which the element publishes on the scroll container, and stays see-through until stuck. */ surface-header[overlay] { margin-block-end: calc(-1 * var(--surface-header-height, 0px)); }
surface-header[overlay]:not(:state(stuck)) { background: var(--surface-header-top-background, transparent); color: var(--surface-header-top-color, var(--surface-header-color, CanvasText)); }
/* Moves past the top edge, offset included, so nothing peeks out. */ surface-header:state(hidden) { translate: 0 calc(-100% - var(--_offset)); }
@media (prefers-reduced-motion: reduce) { surface-header { transition-duration: 0s; } }}`;let adopted = false;/** Adds {@link headerStyles} to the document once. */function adoptHeaderStyles(root = document) { if (adopted) return; adopted = true; if ("adoptedStyleSheets" in root && "replaceSync" in CSSStyleSheet.prototype) { const sheet = new CSSStyleSheet(); sheet.replaceSync(headerStyles); root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; return; } const style = root.createElement("style"); style.dataset["surfaceElements"] = "header"; style.textContent = headerStyles; root.head.append(style);}//#endregion//#region src/header/header.ts/*** A page header that sticks to the top of its scroll container. Wrap your* `<header>` in it so the `banner` landmark stays native. Adds the header* styles to the document, so no CSS import is needed.** Publishes its height as `--surface-header-height` on the scroll container* (`<html>` for the page), for `scroll-padding-block-start`.** It never hides while focus, an open `<details>` or an open popover is* inside it, or while a {@link SurfaceHeader.hold} is active. Mark a section* with `data-surface-header="hidden"` to hide the header while that section* is under it.** @tag surface-header* @slot - The page `<header>`.* @attr {boolean} hide-on-scroll - Hide while scrolling down, show again on scroll up or when focus moves inside.* @attr {boolean} overlay - Overlay the content below (the hero) and use the top colors until stuck.* @cssstate stuck - The header is stuck to the top of its scroll container.* @cssstate hidden - The header is moved out of view, by `hide-on-scroll` or a hidden section.* @cssprop [--surface-header-background=Canvas] - Background while stuck, and always without `overlay`.* @cssprop [--surface-header-color=CanvasText] - Text color while stuck, and always without `overlay`.* @cssprop [--surface-header-top-background=transparent] - Background with `overlay` before it sticks.* @cssprop [--surface-header-top-color=var(--surface-header-color)] - Text color with `overlay` before it sticks.* @cssprop [--surface-header-offset=0px] - Distance from the top edge where the header sticks.* @cssprop [--surface-header-duration=0.2s] - Hide, show and color transition duration.* @cssprop [--surface-header-easing=ease-out] - Hide and show easing.* @cssprop --surface-header-height - Output, not input: the element writes its height here, on its scroll container.*/var SurfaceHeader = class extends HTMLElement { static tagName = "surface-header"; static observedAttributes = ["hide-on-scroll"]; #internals; #scroller; #resizeObserver; #frame = 0; #lastScroll = 0; #hidden = false; #holds = 0; constructor() { super(); this.#internals = this.attachInternals?.(); } /** Whether the header is stuck to the top of its scroll container. */ get stuck() { return this.#internals?.states?.has("stuck") ?? false; } /** * Keeps the header visible until the returned function is called, for a * menu that lives outside it. Ref-counted; the release is idempotent. */ hold() { this.#holds++; this.#setHidden(false); let released = false; return () => { if (released) return; released = true; this.#holds--; }; } connectedCallback() { adoptHeaderStyles(); const scroller = scrollContainer(this); this.#scroller = scroller; this.#lastScroll = scroller.scrollTop; scrollTarget(scroller).addEventListener("scroll", this.#onScroll, { passive: true }); this.addEventListener("focusin", this.#onFocusIn); if ("ResizeObserver" in globalThis) { this.#resizeObserver ??= new ResizeObserver(() => this.#publishHeight()); this.#resizeObserver.observe(this); } this.#publishHeight(); this.#update(); } attributeChangedCallback() { this.#update(); } disconnectedCallback() { const scroller = this.#scroller; if (scroller) { scrollTarget(scroller).removeEventListener("scroll", this.#onScroll); scroller.style.removeProperty("--surface-header-height"); } this.removeEventListener("focusin", this.#onFocusIn); this.#resizeObserver?.disconnect(); cancelAnimationFrame(this.#frame); this.#frame = 0; this.#scroller = void 0; } #onScroll = () => { this.#frame ||= requestAnimationFrame(() => { this.#frame = 0; this.#update(); }); }; #onFocusIn = () => { this.#setHidden(false); }; #update() { const scroller = this.#scroller; if (!scroller) return; const scroll = scroller.scrollTop; const top = this.getBoundingClientRect().top - viewportTop(scroller); const offset = Number.parseFloat(getComputedStyle(this).top) || 0; const stuck = scroll > 0 && (this.#hidden || top <= offset + 1); this.#setState("stuck", stuck); const delta = scroll - this.#lastScroll; const line = viewportTop(scroller) + offset; if (this.#pinned() || !stuck) this.#setHidden(false); else if (this.#overHiddenSection(scroller, line)) this.#setHidden(true); else if (!this.hasAttribute("hide-on-scroll")) this.#setHidden(false); else if (delta < 0) this.#setHidden(false); else if (delta > 0 && scroll > this.offsetHeight + offset) this.#setHidden(true); this.#lastScroll = scroll; } #pinned() { if (this.#holds > 0 || this.contains(document.activeElement)) return true; if (this.querySelector("details[open]")) return true; return [...this.querySelectorAll("[popover]")].some((el) => el.matches(":popover-open")); } #overHiddenSection(scroller, line) { const end = line + this.offsetHeight; return [...scroller.querySelectorAll("[data-surface-header=\"hidden\"]")].some((section) => { const rect = section.getBoundingClientRect(); return rect.top < end && rect.bottom > line; }); } #publishHeight() { this.#scroller?.style.setProperty("--surface-header-height", `${this.offsetHeight}px`); } #setHidden(on) { this.#hidden = on; this.#setState("hidden", on); } #setState(state, on) { const states = this.#internals?.states; if (!states) return; if (on) states.add(state); else states.delete(state); }};/** The nearest scrolling ancestor, or `<html>` for the page. */function scrollContainer(el) { for (let node = el.parentElement; node; node = node.parentElement) { if (node === document.body || node === document.documentElement) break; const { overflowY } = getComputedStyle(node); if (overflowY !== "visible" && overflowY !== "clip") return node; } return document.documentElement;}const isRoot = (scroller) => scroller === document.documentElement;const scrollTarget = (scroller) => isRoot(scroller) ? window : scroller;const viewportTop = (scroller) => isRoot(scroller) ? 0 : scroller.getBoundingClientRect().top + scroller.clientTop;defineElement(SurfaceHeader.tagName, SurfaceHeader);//#endregionexport { SurfaceHeader };Attributes
| Attribute | Type | Description |
|---|---|---|
hide-on-scroll | boolean | Hide while scrolling down, show again on scroll up or when focus moves inside. |
overlay | boolean | Overlay the content below (the hero) and use the top colors until stuck. |
Properties
| Property | Type | Description |
|---|---|---|
stuck | boolean, read-only | Whether the header is stuck to the top of its scroll container. |
Methods
| Method | Returns | Description |
|---|---|---|
hold() | () => void | Keeps the header visible until the returned function is called, for a menu that lives outside it. Ref-counted; the release is idempotent. |
Slots
| Slot | Description |
|---|---|
| (default) | The page <header>. |
CSS states
| Selector | Description |
|---|---|
surface-header:state(stuck) | The header is stuck to the top of its scroll container. |
surface-header:state(hidden) | The header is moved out of view, by hide-on-scroll or a hidden section. |
CSS custom properties
| Property | Default | Description |
|---|---|---|
--surface-header-background | Canvas | Background while stuck, and always without overlay. |
--surface-header-color | CanvasText | Text color while stuck, and always without overlay. |
--surface-header-top-background | transparent | Background with overlay before it sticks. |
--surface-header-top-color | var(--surface-header-color) | Text color with overlay before it sticks. |
--surface-header-offset | 0px | Distance from the top edge where the header sticks. |
--surface-header-duration | 0.2s | Hide, show and color transition duration. |
--surface-header-easing | ease-out | Hide and show easing. |
--surface-header-height | Output, not input: the element writes its height here, on its scroll container. |
Mark a section with data-surface-header="hidden" to hide the header while that section is under it.
Set the offset with --surface-header-offset, not top: the hide transition needs it to move the header fully out of view.
The styles are in the surface-elements cascade layer, so your own rules win. For CSS-only use, import surface-elements/header.css.
If you implement it by hand
Section titled “If you implement it by hand”Minimal markup: a sticky wrapper around a native <header>.
<div class="site-header"> <header>…</header></div>Minimal CSS. The scroll-state container lets descendants style the stuck state with no JavaScript, where the browser supports it:
.site-header { position: sticky; top: 0; z-index: 1; background: Canvas; container: site-header / scroll-state;}
@container site-header scroll-state(stuck: top) { header { box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 0.1); }}What the JavaScript adds, and what happens without it:
- Stuck state everywhere. A
scrolllistener, throttled to one read per frame, compares the element’s position with its sticky offset and sets:state(stuck). Without it, only browsers with scroll-state queries (Chromium) get stuck styles; the header still sticks everywhere. - Overlay.
margin-block-end: calc(-1 * height)pulls the hero under the header; the stuck state switches the colors. Without JS there is no stuck state, so use@container scroll-state(stuck: top)for the colors. - Hidden sections and holds. The same frame checks for marked sections under the header’s band, open
<details>/popovers inside it, and active holds. - Hide on scroll. The same listener compares each scroll position with the last one: down past the header’s own height hides it (
translatepast the top edge), up shows it.focusinshows it. Without it, the header simply stays. - Height variable. A
ResizeObserverwrites--surface-header-height. Without it, hard-code the height inscroll-padding-block-start.
Accessibility checklist:
- Keep a native
<header>(top level, not inside<main>or<article>) so it is thebannerlandmark. - Never leave focus on a hidden header: show it on
focusin, and do not hide while it holds focus. - Under
prefers-reduced-motion: reduce, hide and show without sliding. - Set
scroll-padding-block-startso focused elements and anchor targets are not covered.
Browser support and fallbacks:
position: stickyand custom states (:state()) are Baseline.- Scroll-state container queries are Chromium only. The element’s
:state(stuck)covers the other browsers.