Follows your scroll
The header translates at exactly the rate you scroll — no easing, no lag, no trigger points. When scrolling stops it settles to fully shown or fully hidden, whichever side of the threshold it landed on.
npm install @magic-spells/sticky-header
6.1 kB JS gzip · 0.3 kB CSS gzip
Everything you need
<!-- or from npm: import '@magic-spells/sticky-header' + '/css' -->
<link rel="stylesheet"
href="https://unpkg.com/@magic-spells/sticky-header/dist/sticky-header.min.css">
<script src="https://unpkg.com/@magic-spells/sticky-header"></script>
<style>
/* the bounce cap inherits this, so it belongs on the host */
sticky-header { background: #0f0e14; }
</style>
<sticky-header hide-on-scroll="both" breakpoint="1024" hover-lock>
<!-- no tag: top-only. Leaves with the page, back near the top -->
<div class="countdown">12 hrs left</div>
<!-- tagged for mobile: comes back on a scroll up, small screens only -->
<div data-announcement data-sticky-reveal="mobile">Free shipping</div>
<!-- bare tag = both. The reveal boundary on desktop -->
<header data-sticky-reveal>
<a href="/">Brand</a>
<nav>…</nav>
</header>
</sticky-header>
<sticky-content>
<nav class="section-tabs">…</nav> <!-- pins below, rides the offset -->
</sticky-content>
- Follows the scroll 1:1 — no trigger points, no lag
- Settles to a stop on scroll rest, with a tunable bounce
- Scroll-up reveal boundaries via
data-sticky-reveal - Top-only bars glide away and come back at the same position
- Hides per breakpoint — mobile, desktop, both or never
- Hover-lock,
dialog[open]and any selector you name <sticky-content>riders pin below and ride the offset- Live geometry in CSS variables at every breakpoint
- Reduced-motion fallback, no CSS transitions to fight
- Light DOM and plain custom properties — style it with your own CSS
API
<sticky-header> attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
hide-on-scroll |
none mobile desktop both |
both |
Which viewports hide on scroll. Height variables are maintained in every mode |
breakpoint |
px number | 1024 |
The desktop cutoff. desktop matches (min-width: bp);
mobile is everything narrower
|
reveal-threshold |
px | 100 |
Within this distance of the page top the header is always fully visible |
settle-threshold |
0–1 |
0.5 |
How far into the gap between the two adjacent stops an idle settle commits to the hidden one rather than back to the reveal stop |
settle-duration |
ms | 900 |
Hide settle duration; the show settle uses 85% of it |
settle-overshoot |
0–0.2 |
0.05 |
Bounce amplitude. 0 swaps to a critically damped curve — no bounce
|
hover-lock |
boolean | — | While the pointer is anywhere in the header group, don't hide on scroll |
lock |
CSS selector | — |
Extra force-show condition, e.g. an open menu panel. dialog[open] is
built in
|
locked |
boolean | — | Force fully visible |
disabled |
boolean | — | Turn tracking off entirely (height variables are still maintained) |
Descendant attributes — the reveal boundary
| Attribute | Values | On | Description |
|---|---|---|---|
data-sticky-reveal |
both mobile desktop none, or bare |
Any descendant |
Comes back on a mid-page scroll up. The topmost active tag is the
boundary; everything above it is top-only. Bare = both;
none and unrecognized values are inactive; an element with no box is
skipped
|
<sticky-content> attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
top |
CSS length | --header-group-height |
Resting sticky inset. Sugar for setting --sticky-content-top |
disabled |
boolean | — |
Stop riding the header; pin at the static resting inset instead.
stuck is no longer applied
|
Custom properties — written on <body>
| Property | Description |
|---|---|
--header-group-offset |
The rider hook. Per frame, 0 → −(group height);
briefly a little past 0 during the show bounce
|
--header-group-height |
Total height of the sliding group |
--header-height |
The header alone, without the announcement bar |
--announcement-bar-height |
The data-announcement child, when there is one |
--header-reveal-offset |
The middle resting stop — −(distance from the group top to the reveal boundary).
0px with nothing tagged
|
Custom properties — set by you
| Property | Default | Description |
|---|---|---|
--sticky-content-top |
--header-group-height |
A rider's resting inset |
--sticky-header-z-index |
100 |
Header stacking order |
--sticky-content-z-index |
10 |
Rider stacking order |
State attributes
| Attribute | On | When |
|---|---|---|
data-state="top|scrolling" |
<body> |
Within 8px of the page top, or not |
data-header-hidden |
<body> |
Settled fully hidden — never mid-transition |
data-header-revealed |
<body> |
Settled at the reveal stop, with a boundary strictly inside the group |
data-header-tracking |
<body> |
Tracking or settling. Use it to suppress your own transitions |
data-header-locked |
<body> |
A lock condition is holding the header visible |
data-hidden data-tracking |
<sticky-header> |
Element-level mirrors of the above |
stuck |
<sticky-content> |
The rider is pinned at its effective top — a styling hook |
Events — bubble, dispatched on <sticky-header>
| Event | When | detail |
|---|---|---|
sticky-header:settle |
A settle tween starts | { target: 'show'|'hide', from, duration } |
sticky-header:hide |
Settled fully hidden | — |
sticky-header:show |
Settled anywhere short of fully hidden — at a partial reveal stop this fires
alongside reveal
|
— |
sticky-header:reveal |
Settled at the reveal stop | — |
sticky-header:resize |
Measured geometry changed | { headerHeight, announcementHeight, groupHeight } |
Properties and methods
| Member | On | Description |
|---|---|---|
offset |
<sticky-header> |
Current offset in px (0 shown → −groupHeight hidden) |
progress |
<sticky-header> |
Hidden fraction over the full travel, 0 → 1 |
revealOffset |
<sticky-header> |
The reveal stop in px, 0 with nothing tagged |
isHidden |
<sticky-header> |
Settled fully hidden. Named to avoid shadowing native
HTMLElement.hidden
|
groupHeight |
<sticky-header> |
Measured height of the sliding group |
show() hide() |
<sticky-header> |
Settle fully visible / fully hidden (hide is ignored while locked or inactive) |
lock() unlock() |
<sticky-header> |
Toggle the locked attribute |
refresh() |
<sticky-header> |
Re-measure geometry and rebase scroll tracking |
stuck |
<sticky-content> |
Whether the rider is pinned at its effective top |
top |
<sticky-content> |
Resting inset in px, excluding the header offset |