Skip to content

Sticky header

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 inside the frame
StoreContent

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.

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.

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?.()
})

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>

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.

: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.

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.js
/*! <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.ts
function 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);
//#endregion
export { SurfaceHeader };

Attributes

AttributeTypeDescription
hide-on-scrollbooleanHide while scrolling down, show again on scroll up or when focus moves inside.
overlaybooleanOverlay the content below (the hero) and use the top colors until stuck.

Properties

PropertyTypeDescription
stuckboolean, read-onlyWhether the header is stuck to the top of its scroll container.

Methods

MethodReturnsDescription
hold()() => voidKeeps the header visible until the returned function is called, for a menu that lives outside it. Ref-counted; the release is idempotent.

Slots

SlotDescription
(default)The page <header>.

CSS states

SelectorDescription
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

PropertyDefaultDescription
--surface-header-backgroundCanvasBackground while stuck, and always without overlay.
--surface-header-colorCanvasTextText color while stuck, and always without overlay.
--surface-header-top-backgroundtransparentBackground with overlay before it sticks.
--surface-header-top-colorvar(--surface-header-color)Text color with overlay before it sticks.
--surface-header-offset0pxDistance from the top edge where the header sticks.
--surface-header-duration0.2sHide, show and color transition duration.
--surface-header-easingease-outHide and show easing.
--surface-header-heightOutput, 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.

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 scroll listener, 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 (translate past the top edge), up shows it. focusin shows it. Without it, the header simply stays.
  • Height variable. A ResizeObserver writes --surface-header-height. Without it, hard-code the height in scroll-padding-block-start.

Accessibility checklist:

  • Keep a native <header> (top level, not inside <main> or <article>) so it is the banner landmark.
  • 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-start so focused elements and anchor targets are not covered.

Browser support and fallbacks:

  • position: sticky and custom states (:state()) are Baseline.
  • Scroll-state container queries are Chromium only. The element’s :state(stuck) covers the other browsers.