Sticky header that hides on scroll: CSS-first approach + minimal JS fallback
You shipped a sticky header. Marketing loved it. Users didn’t. Now every scroll feels like pushing a refrigerator up a hill: janky, jumpy, and occasionally covering the very UI it’s supposed to help you reach.
The goal here is simple: a header that stays available when the user scrolls up (helpful), and tucks away when the user scrolls down (polite). We’ll do it CSS-first, because CSS is stable under load. Then we’ll add a tiny JavaScript fallback for the parts CSS can’t infer: scroll direction and “did we actually pass the top yet?”
What you actually want (and what you don’t)
A “sticky header that hides on scroll” sounds like a design flourish. In production, it’s a control loop. You’re measuring scroll position, inferring user intent, and mutating layout or paint in response. That’s a reliability problem in a trench coat.
Here’s the practical spec I recommend:
- At top of page: header is visible, not elevated (no shadow), doesn’t “bounce.”
- Scrolling down: header hides after a small threshold (avoid flicker on micro-scroll).
- Scrolling up: header reappears quickly (navigation and search are now useful).
- Anchors / in-page navigation: content headings are not covered by the header.
- Reduced motion: no sliding animation if the OS asked for less motion.
- Mobile Safari: doesn’t jitter, doesn’t trap taps, respects safe areas.
- Zero CLS budget: the header must not cause layout shift once rendered.
What you don’t want:
- Hiding/showing by changing
heightordisplayduring scroll. That’s layout work. You’ll pay for it on every frame. - A scroll handler that does more than set a boolean. The browser already has a full-time job drawing pixels.
- “Works on my MacBook Pro” as a performance bar. The median phone doesn’t care about your feelings.
One rule to tattoo on your code review process: animate transforms, not layout. If your implementation causes layout thrash, it will eventually meet a page with heavy content, and it will lose.
Facts and short history: why this is harder than it looks
Sticky headers look like a solved problem because you’ve seen them a thousand times. Under the hood, they’re a handshake between scrolling mechanics, compositing, viewport quirks, and accessibility expectations. Some quick context points that matter when you’re debugging this at 2 a.m.:
position: stickywas standardized after years of vendor experiments. Early “sticky” behavior often depended on JS libraries that polyfilled everything via scroll listeners.- Mobile browsers have two “viewports” in practice. The layout viewport and the visual viewport can differ (especially with address bar collapse), which affects “top: 0” expectations.
- iOS Safari historically struggled with fixed/sticky during rubber-band scrolling. Even today, overscroll behaviors and the dynamic toolbar can produce edge-case jitter.
- Scroll events were once synchronous and expensive. Browsers moved toward async scrolling to keep the UI responsive, which is why modern scroll handlers must be lightweight and often passive.
IntersectionObserverwas introduced to avoid constant scroll polling. It’s a big deal for “am I past a sentinel?” logic without burning CPU per pixel.- Core Web Vitals put numbers behind “feels bad.” CLS and INP will rat you out even if your QA team missed the jitter.
- “Scroll anchoring” exists to prevent content jumps. But headers that change height can defeat it and reintroduce jumps users interpret as brokenness.
- Safe area insets became a web concern with notched phones. If your header ignores
env(safe-area-inset-top), you’ll have clipped content on some devices.
Joke #1: A sticky header is like a storage quota—nobody notices until it breaks, and then it’s suddenly everybody’s highest priority.
CSS-first baseline: sticky, safe offsets, and no layout shifts
The CSS-first approach means: get 80% of the behavior without JavaScript. That gives you a stable baseline: no dependency on scroll handlers, no surprises when the main thread is busy, and fewer “it only happens on this one page” incidents.
Baseline header CSS (sticky + transform animation)
Use position: sticky and keep the header’s height constant. When hiding, translate it out of view with transform. This is compositing-friendly and usually avoids layout recalculation.
The header in this page already implements the baseline: sticky positioning, a constant height, transform-based hiding, safe-area padding, and :target scroll margin to avoid anchor overlap.
Prevent anchor targets from hiding under the header
If your in-page navigation uses #hash targets (TOC links, “jump to section”), the browser scrolls the target to the top. With a sticky header, “the top” is now behind a slab of UI.
The low-tech fix is solid: scroll-margin-top on headings or a global :target rule. That’s what we’re using:
cr0x@server:~$ cat ui.css | sed -n '1,40p'
:target {
scroll-margin-top: calc(var(--header-h) + 16px);
}
Output meaning: you’re adding scroll margin for any element that becomes the target of a fragment navigation. Decision: apply this globally if your document structure is consistent; otherwise scope it to h2, h3 to avoid odd offsets on other targets.
Don’t let content jump when the header “hides”
If hiding the header changes its height, the page content shifts upward. That’s classic CLS. Users perceive it as “the site is moving under my finger.” Keep height fixed. Hide with transform.
This also avoids a failure mode where the header hides, content shifts, and the browser tries to keep the current scroll anchor visible, causing extra jumps. It’s like two control systems fighting each other in midair.
Respect reduced motion by default
Sliding headers can be motion-triggering for some users. Make it instant under prefers-reduced-motion: reduce. You can still toggle visibility; just skip the animated transition.
Three viable patterns (pick one on purpose)
Pattern A: “Sticky always visible” (CSS-only, boring, reliable)
This isn’t what the topic promises, but it’s the baseline you should start from. A sticky header that never hides has fewer moving parts and usually better accessibility. If your header is tall or your content is dense, it may still be the best product decision.
- Pros: simplest, least jank, easiest to reason about.
- Cons: eats vertical space, especially painful on small screens.
Pattern B: “Hide on down, show on up” (minimal JS, best overall)
This is the standard behavior users expect because it matches intent: if they’re reading downward, get out of the way; if they reverse direction, they probably want navigation.
- Pros: works everywhere, predictable, can be tuned with thresholds.
- Cons: requires JS to infer direction; must be careful about performance.
Pattern C: “Hide after sentinel, show near top” (IntersectionObserver + optional direction)
If you hate scroll listeners (reasonable), use a sentinel element near the top. When it leaves the viewport, elevate the header (shadow) and optionally enable hiding logic. This makes the “top-of-page” state robust.
- Pros: fewer scroll computations; stable “am I at top?” detection.
- Cons: still needs direction detection for the full hide-on-down behavior; can get tricky with dynamic toolbars.
Minimal JS fallback: direction, thresholds, and state
CSS can’t know scroll direction. It can react to state you set. So the right shape is: JS reads scroll position, sets a couple of data attributes, and gets out of the way.
Keep the state machine tiny:
data-hidden: booleandata-elevated: boolean (shadow once you’re off the top)
A production-grade tiny script
This is the minimal JS I’m willing to defend in a performance review. It uses requestAnimationFrame to coalesce scroll events, a threshold to avoid flicker, and it avoids doing work when nothing changed.
cr0x@server:~$ cat sticky-header.js
(() => {
const header = document.querySelector('.site-header');
if (!header) return;
const hideThreshold = 12; // px of downward movement before hiding
const showThreshold = 6; // px of upward movement before showing
const elevateAfter = 4; // px from top before adding shadow
const topSnap = 0; // treat 0 as top; adjust for visual viewport if needed
let lastY = window.scrollY || 0;
let lastDir = 0; // -1 up, +1 down
let rafPending = false;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function update() {
rafPending = false;
const y = window.scrollY || 0;
const dy = y - lastY;
// Determine direction with deadzone to avoid noise.
let dir = lastDir;
if (dy >= hideThreshold) dir = 1;
else if (dy <= -showThreshold) dir = -1;
// Elevated when not at top.
const elevated = y > elevateAfter;
// Hide only when scrolling down and not near top.
let hidden = header.dataset.hidden === 'true';
if (y <= topSnap) {
hidden = false;
} else if (dir === 1) {
hidden = true;
} else if (dir === -1) {
hidden = false;
}
// Apply only on changes.
if ((header.dataset.elevated === 'true') !== elevated) {
header.dataset.elevated = elevated ? 'true' : 'false';
}
if ((header.dataset.hidden === 'true') !== hidden) {
header.dataset.hidden = hidden ? 'true' : 'false';
}
lastY = y;
lastDir = dir;
}
function onScroll() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(update);
}
window.addEventListener('scroll', onScroll, { passive: true });
// Run once on load in case the page loads mid-scroll.
update();
})();
Output meaning: you now have a deterministic toggler that will not run more than once per animation frame. Decision: use this exact shape if you care about performance; don’t let it grow tentacles.
IntersectionObserver sentinel: robust “top state” without guessing
The weirdest bugs in these headers come from “are we at the top?” logic under mobile toolbars. A sentinel element at the top of the content gives you a crisp signal: if it intersects, you’re at/near top.
You can combine the sentinel with the direction script above, or just use it for elevation and avoid elevation flicker entirely.
cr0x@server:~$ cat sentinel.js
(() => {
const header = document.querySelector('.site-header');
const sentinel = document.querySelector('[data-top-sentinel]');
if (!header || !sentinel || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver((entries) => {
const e = entries[0];
// When sentinel is visible, we're near the top: no shadow.
header.dataset.elevated = e.isIntersecting ? 'false' : 'true';
}, { root: null, threshold: [0, 1] });
io.observe(sentinel);
})();
Output meaning: elevation state is now driven by intersection, not by scrollY heuristics. Decision: prefer this if you have dynamic top banners, collapsing bars, or complicated layout where “top” isn’t just scrollY==0.
Joke #2: If you attach three scroll listeners, the browser doesn’t “multi-thread it,” it just silently judges you.
Accessibility and “don’t break the back button” rules
Hiding a header is a UX choice. If you implement it carelessly, it becomes an accessibility defect.
Keyboard and focus: never hide focused controls
If the header contains a search input or navigation links, the user might tab into it. If your script hides the header while a control inside has focus, you’ve created a “now you see it, now you don’t” interaction that’s hostile to keyboard users.
Fix: if header.contains(document.activeElement) is true, force it visible. It’s a small conditional, and it prevents a surprisingly nasty bug class.
cr0x@server:~$ rg "activeElement" -n sticky-header.js
Output meaning: no results indicates you haven’t added focus protection. Decision: add it if the header contains interactive elements (it almost always does).
Screen readers: avoid removing content from the accessibility tree
Sliding the header out with transform keeps it in the DOM and the accessibility tree. That’s typically fine. Don’t set display: none as your “hidden” mechanism unless you’re willing to handle focus, aria state, and reflow consequences.
If you must fully hide it, use careful focus management and consider setting inert (where supported) to prevent tabbing into off-screen controls. But now you’re building a UI framework. Try not to.
Reduced motion isn’t optional
You already saw the CSS. Keep it. Also consider skipping direction-based hide/show entirely under reduced motion if the product allows it. Sliding UI that reacts to scroll can feel like the page is “alive.” Some users do not want an alive page.
Don’t break the browser’s built-in scroll restoration
The browser tries to restore scroll position on back/forward navigation. If your header changes height or triggers layout during initial paint, you can get odd “restore to wrong spot then jump” behavior.
Best practice: keep the header’s layout stable from the first frame. Avoid loading a giant webfont late that changes header height. If you can’t avoid it, set explicit heights and use font-display strategies that don’t reflow the header.
Performance: where jank comes from (and how to kill it)
Scrolling performance is a budget. The browser wants to hit ~60fps on common hardware. That gives you roughly 16ms per frame, and that’s shared with everything else: layout, paint, compositing, JS, images, ads, analytics, you name it.
Why transforms usually win
A transform: translateY() animation can often be handled by the compositor thread. That means it can keep moving even if the main thread is busy. “Often” is doing work in that sentence; you still need to avoid forcing layout and heavy paints.
The three main jank sources for hide-on-scroll headers
- Layout thrash: toggling properties like
height,top, or classes that reflow the page on every scroll event. - Main-thread overload: scroll handler does too much, or triggers style recalculation repeatedly.
- Paint storms: shadows, blurs, and translucent backgrounds that repaint huge areas during transforms on low-end GPUs.
Make shadows conditional, not constant
A big drop shadow looks great. It also can be expensive, especially on mobile. Only apply it when you’re off the top, and consider simpler shadows for low-end devices. The “elevated” attribute approach gives you a clean toggle.
Use passive listeners and requestAnimationFrame
Passive scroll listeners tell the browser you won’t call preventDefault(), so it can scroll without waiting for your JS. requestAnimationFrame batching keeps you from doing redundant work between frames.
“Hope is not a strategy.” —General H. Norman Schwarzkopf
It applies to UI reliability too. If you “hope” your scroll handler is fine because it’s small, you’ll ship a regression when someone adds analytics calls or DOM queries in the same loop.
Three corporate mini-stories (the kind you learn from)
Mini-story 1: The incident caused by a wrong assumption
An internal dashboard team rolled out a new global header with “hide on scroll” behavior. It was supposed to help analysts see more rows in a dense table. The implementation looked clean: a scroll listener compared window.scrollY to the last value and toggled display: none on the header.
The wrong assumption was subtle: “Hiding the header is the same as translating it away.” In their mental model, the header was purely visual. In the browser’s model, changing display affects layout, which affects scroll height, which affects scroll position, which triggers more scroll events.
On pages with virtualized tables, the header being removed changed the available viewport height. The table recalculated row rendering. That triggered layout. Layout changed scroll position slightly. The scroll handler saw movement and toggled again. The result wasn’t a full infinite loop, but it was a violent flicker that spiked CPU and made the page feel broken.
It only reproduced on certain machines because performance determined whether the oscillation dampened or amplified. The incident report ended with the least glamorous fix in history: keep the header in the layout, hide with transform, and add a pixel threshold to ignore noise. Nobody got promoted for it, but the graphs stopped screaming.
Mini-story 2: The optimization that backfired
A product site wanted buttery scrolling on low-end Android devices. Someone suggested “GPU-accelerate everything” and added will-change: transform to the header, the hero, the CTA rail, and a few other components. The thinking: pre-promote elements to layers, avoid jank.
For a few days it looked better in a couple of dev devices. Then real user monitoring started showing higher memory use and more “tab reload” events on mobile. The promotion to layers increased GPU memory pressure, and on some devices the browser got aggressive about reclaiming resources.
The header itself was fine. The problem was systemic: will-change is not a magic spell; it’s a hint that costs memory. Too many promoted layers can cause the compositor to thrash or trigger tile uploads. The “optimization” turned into a reliability hit.
The fix was to use will-change only on the header (and only when needed), simplify the shadow, and remove it from everything else. Scrolling became boring again, which is the highest compliment a UI can earn in production.
Mini-story 3: The boring but correct practice that saved the day
A large enterprise web app had a rule: any global UI behavior had to ship behind a feature flag, with a kill switch controlled by ops. It wasn’t sexy. Engineers occasionally rolled their eyes. Then a browser update landed.
The update changed something about scroll behavior on a subset of devices. The hide-on-scroll header began to stutter, but only when an embedded third-party widget was present. The widget injected a large fixed-position element that altered compositing decisions. Users complained that the header “vibrates.”
Because the feature was flagged, the on-call toggled the header behavior off for affected user agents while the team investigated. The site stayed usable. Nobody had to hotfix at midnight under pressure. The next day, they patched the logic to avoid toggling while the widget was animating and adjusted thresholds for that browser.
The lesson is dull and permanent: ship UI behaviors with a way to disable them. Not because you expect failure, but because reality is inventive.
Practical tasks: commands, outputs, and decisions
You asked for real tasks, not vibes. These are the checks I run when a “hide on scroll” header feels wrong in production. They’re a mix of server-side verification (did we deploy the right assets?), client-side debugging (are we shipping too much?), and performance diagnosis.
Task 1: Verify the deployed CSS contains sticky + transform rules
cr0x@server:~$ grep -nE "position:\s*sticky|will-change:\s*transform|translateY" /var/www/app/static/ui.css | head
132:header.site-header { position: sticky;
139: will-change: transform;
151:header.site-header[data-hidden="true"] { transform: translateY(calc(-1 * var(--header-h)));
Output meaning: the key properties exist in the deployed artifact. Decision: if missing, your build pipeline likely shipped an old bundle or a different theme; fix deployment before debugging “performance.”
Task 2: Confirm the header height is constant in CSS (no animated height)
cr0x@server:~$ grep -nE "header\.site-header|height:" -n /var/www/app/static/ui.css | sed -n '120,175p'
132:header.site-header {
145: height: var(--header-h);
151:header.site-header[data-hidden="true"] {
Output meaning: height is set once, not toggled. Decision: if you see height changes in different states, expect layout shift and reflow; refactor to transforms.
Task 3: Validate JS bundle actually contains the minimal scroll handler
cr0x@server:~$ rg -n "requestAnimationFrame\\(update\\)|passive:\\s*true|data-hidden" /var/www/app/static/app.js | head
8432: requestAnimationFrame(update);
8440: window.addEventListener('scroll', onScroll, { passive: true });
8456: header.dataset.hidden = hidden ? 'true' : 'false';
Output meaning: the important parts are present. Decision: if you don’t see passive listeners or rAF, you’re likely doing too much work per scroll event; fix that first.
Task 4: Check gzip/brotli effectiveness for JS/CSS (shipping less matters)
cr0x@server:~$ curl -sI -H 'Accept-Encoding: br' http://localhost/static/app.js | grep -iE 'content-encoding|content-length|cache-control'
Content-Encoding: br
Content-Length: 182943
Cache-Control: public, max-age=31536000, immutable
Output meaning: brotli is enabled; payload size is visible; caching is long-lived. Decision: if no compression or caching, fix that before micro-optimizing scroll math.
Task 5: Confirm correct MIME types (avoids odd browser behavior and caching issues)
cr0x@server:~$ curl -sI http://localhost/static/ui.css | grep -iE 'content-type|cache-control'
Content-Type: text/css; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
Output meaning: correct MIME type and caching. Decision: if MIME is wrong, some browsers treat assets differently; fix server config.
Task 6: Detect accidental duplicate scroll listeners in the bundle
cr0x@server:~$ rg -n "addEventListener\\('scroll'" /var/www/app/static/app.js | head -n 20
8440: window.addEventListener('scroll', onScroll, { passive: true });
12110: window.addEventListener('scroll', trackScrollDepth, { passive: true });
17822: document.addEventListener('scroll', legacyScrollHandler);
Output meaning: multiple scroll listeners exist. Decision: audit them. If you see a “legacyScrollHandler,” you likely have competing behaviors and unnecessary work; remove or gate behind flags.
Task 7: Confirm the page isn’t forcing layout on scroll (find layout-triggering code)
cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight|clientHeight" /var/www/app/static/app.js | head
5209: const h = header.offsetHeight;
10902: const rect = el.getBoundingClientRect();
Output meaning: these calls can trigger layout if mixed with writes. Decision: ensure these reads are not inside the scroll handler or are isolated before writes; otherwise you’ll create forced synchronous layout.
Task 8: Check Nginx access logs for asset churn (are users re-downloading constantly?)
cr0x@server:~$ sudo awk '$7 ~ /\/static\/(app\.js|ui\.css)/ {print $7, $9}' /var/log/nginx/access.log | tail -n 8
/static/ui.css 200
/static/app.js 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
Output meaning: many 200s suggest caching might be broken (should be 304s or cache hits at CDN). Decision: verify cache headers and CDN config; repeated downloads delay interactivity and can worsen scroll jank after navigation.
Task 9: Check server response times for the HTML (slow TTFB can delay CSS/JS)
cr0x@server:~$ curl -o /dev/null -s -w "ttfb=%{time_starttransfer} total=%{time_total}\n" http://localhost/
ttfb=0.043 total=0.051
Output meaning: fast TTFB and total. Decision: if TTFB is high, your “header jank” might be a symptom of late-loading CSS/JS due to slow HTML delivery; fix backend or caching first.
Task 10: Validate that you’re not shipping unbounded third-party scripts
cr0x@server:~$ rg -n "googletagmanager|segment|hotjar|fullstory|datadogRum" /var/www/app/templates/index.html
42:<script>/* datadogRum init */</script>
Output meaning: third-party runtime is present. Decision: if scroll feels worse only after analytics initialize, you may need to delay non-critical scripts, sample aggressively, or isolate them from scroll paths.
Task 11: Check for layout shift signals in browser logs captured by tooling (Lighthouse CI style)
cr0x@server:~$ jq '.audits["cumulative-layout-shift"].numericValue' ./lighthouse-report.json
0.19
Output meaning: CLS is non-trivial. Decision: inspect whether the header height or top padding changes after load, and whether late fonts or banners push content. Fix CLS before polishing the hide animation.
Task 12: Confirm header isn’t taller on iOS due to safe-area miscalculation
cr0x@server:~$ grep -n "safe-area-inset-top" -n /var/www/app/static/ui.css
88:header.site-header { padding-top: env(safe-area-inset-top); height: calc(var(--header-h) + env(safe-area-inset-top)); }
Output meaning: safe area is explicitly handled. Decision: if missing and you have iOS notch users, add it; clipped nav is a real support ticket generator.
Task 13: Validate feature-flag kill switch exists for the behavior
cr0x@server:~$ rg -n "HIDE_HEADER_ON_SCROLL|featureFlag.*header" /var/www/app/static/app.js | head
902: if (!window.__FLAGS__?.HIDE_HEADER_ON_SCROLL) return;
Output meaning: the behavior can be disabled. Decision: if you don’t have this, you’re choosing to debug production by redeploying. That’s a lifestyle choice, not an engineering one.
Task 14: Check error logs for JS exceptions that leave the header stuck hidden
cr0x@server:~$ sudo journalctl -u nginx -n 50 --no-pager | tail -n 10
Dec 29 10:14:03 web nginx[2213]: 2025/12/29 10:14:03 [warn] 2213#2213: *8930 upstream response is buffered to a temporary file
Output meaning: this is server-side and not directly JS-related, but it’s a reminder to check client error telemetry too. Decision: if client-side exceptions exist around scroll code, add try/catch boundaries and fail open (header visible).
If you’re wondering why a storage engineer is telling you to check caching headers: it’s because latency and payload size are user experience, and user experience is production.
Fast diagnosis playbook
When the sticky header misbehaves, don’t “tune the thresholds” first. That’s how you waste an afternoon. Diagnose like an SRE: isolate, measure, reduce variables.
First: is it layout shift or scroll jank?
- Check: does content move up/down when the header hides/shows?
- Interpretation: if yes, you’re doing layout changes (height/display/margins) or loading assets that change header size.
- Action: make header height constant; use transform; set explicit sizes for fonts/icons.
Second: are there too many scroll listeners or expensive work inside them?
- Check: search bundle for
addEventListener('scroll', measure count, find legacy handlers. - Interpretation: multiple handlers often compete and trigger layout reads/writes.
- Action: consolidate into one rAF-batched handler; move analytics off the scroll path.
Third: is the compositor struggling (paint storms, heavy shadows, translucent backdrops)?
- Check: does stutter correlate with heavy content or on low-end devices only?
- Interpretation: large blurs/shadows on moving elements can be expensive.
- Action: simplify shadow; avoid backdrop-filter; reduce alpha layers; don’t overuse will-change.
Fourth: is it a mobile viewport quirk (Safari dynamic toolbar)?
- Check: does it happen only on iOS Safari, especially when address bar collapses/expands?
- Interpretation: scrollY/top detection can be noisy.
- Action: use IntersectionObserver sentinel for “top” state; add thresholds; avoid relying on exact scrollY==0.
Fifth: is it an interaction bug (focus, keyboard, tap targets)?
- Check: tab into header elements; does it disappear while focused?
- Interpretation: you’re hiding without considering focus/interaction state.
- Action: force visible when focused; consider a short lockout timer after interactions.
Common mistakes: symptom → root cause → fix
1) Symptom: header flickers rapidly on trackpads or touch
Root cause: direction detection has no deadzone; tiny deltas toggle state constantly.
Fix: add separate thresholds for hide/show; keep last direction until threshold exceeded; update state in rAF.
2) Symptom: content “jumps” when header hides or shows
Root cause: hiding changes layout (height/display/margins) or late-loading fonts/icons change header size.
Fix: keep header height constant; use transform; set explicit heights; avoid late reflow in header.
3) Symptom: header covers section headings after clicking TOC links
Root cause: no anchor offset handling.
Fix: use scroll-margin-top on headings or :target with header height.
4) Symptom: header is stuck hidden after navigating back
Root cause: state restored incorrectly; script initializes before the header exists; or JS exception stops updates.
Fix: run an initial update(); fail open (visible) on errors; ensure script runs after DOM is ready or uses defer.
5) Symptom: scrolling is smooth until analytics loads, then it’s choppy
Root cause: main thread contention; analytics does work during scroll or triggers layout reads.
Fix: remove analytics hooks from scroll; sample events; use IntersectionObserver for scroll depth; defer non-critical scripts.
6) Symptom: works on desktop Chrome, stutters on iOS Safari
Root cause: dynamic toolbar viewport changes; rubber-band overscroll; compositing differences.
Fix: use sentinel for top state; avoid exact comparisons to scrollY==0; keep animations transform-only; respect safe areas.
7) Symptom: keyboard users lose the focused element
Root cause: header hides while focused; or hidden state removes elements from layout.
Fix: don’t hide when header.contains(document.activeElement); avoid display: none for hide behavior.
8) Symptom: header feels “laggy” (shows late when scrolling up)
Root cause: thresholds too large; scroll handler only runs intermittently; or heavy work delays rAF.
Fix: keep show threshold smaller than hide threshold; reduce work in handler; remove expensive DOM reads.
9) Symptom: header shadow repaints the whole page during scroll
Root cause: heavy shadows/backdrop filters on a moving element cause costly paints.
Fix: simplify shadow; avoid blur filters; toggle shadow only when needed; consider border instead of shadow.
10) Symptom: header overlaps notch / status bar on iPhones
Root cause: safe area not handled.
Fix: add padding-top: env(safe-area-inset-top) and adjust header height accordingly.
Checklists / step-by-step plan
Step-by-step implementation plan (do it in this order)
- Ship a boring sticky header first.
position: sticky; top: 0;fixed height, no hide behavior. - Add anchor offsets. Use
:target { scroll-margin-top: ... }or apply to headings. - Add “elevated” state only. Shadow/border after leaving the top, driven by a sentinel or small scrollY threshold.
- Add hide/show with transform only. No height changes. No display toggles.
- Add direction detection with thresholds. Hide threshold larger than show threshold.
- Guard interaction states. Don’t hide while focused or during active pointer interaction if needed.
- Respect reduced motion. Disable transitions (and possibly behavior) under
prefers-reduced-motion. - Feature-flag it. Add kill switch; default on only after testing.
- Measure. Track CLS and INP; check scroll performance on representative devices.
Release checklist (the SRE-flavored one)
- Header height constant across states (inspect computed styles).
- Hide uses
transform, not layout properties. - Only one scroll listener for the behavior; it is passive and rAF-batched.
- Anchors don’t land under the header.
- Safe area handled for iOS.
- Reduced motion honored.
- Fail open: if JS fails, header stays visible and usable.
- Feature flag + kill switch validated in production config.
- RUM dashboards watched for CLS/INP regressions after rollout.
Tuning checklist (thresholds and feel)
- Start with hide threshold ~10–16px and show threshold ~4–8px.
- Ensure “at top” forces visible and non-elevated.
- Prefer “show quickly, hide reluctantly.” Users forgive a header that appears; they hate one that blocks them.
- If the page has infinite scroll, be extra conservative with hide. The user is already doing a lot of scrolling; don’t add surprises.
FAQ
1) Can this be done with CSS only?
Not fully. CSS can make something sticky, and it can animate a hide/show state once that state is expressed. But “scroll direction” isn’t a CSS input today. If you need hide-on-down/show-on-up, you need JS or a platform feature that doesn’t exist yet.
2) Should I use position: fixed instead of sticky?
Use sticky unless you have a strong reason. Sticky participates in the normal flow, which avoids some layout edge cases. Fixed is fine, but it’s easier to accidentally create overlap, and you’ll need to manage top padding/margins yourself to prevent content hiding.
3) Why not toggle display: none when hiding?
Because it changes layout. That can create CLS, fight scroll anchoring, and cause expensive reflow. Transform-based hiding keeps layout stable and usually scrolls better.
4) Is will-change: transform always good?
No. It consumes resources by encouraging layer promotion. Use it sparingly, and preferably on the one element you’re actually animating (the header). Don’t carpet-bomb your UI with it.
5) What about backdrop-filter for that frosted-glass header?
It can look great and perform terribly, especially during movement. If you must use it, test on low-end devices and consider disabling it during hide/show transitions or behind a capability check.
6) How do I stop the header from hiding when the user scrolls inside a nested container?
Decide which scroll container drives the behavior. If your content scrolls inside a div, use that element’s scroll events and measurements instead of window. Mixing them is a classic source of “it hides at random.”
7) Why does it behave differently on iOS Safari?
Dynamic toolbars, overscroll, and viewport differences. Avoid logic that relies on exact pixel positions at the top. Use a sentinel for top detection and add thresholds so tiny oscillations don’t toggle state.
8) How do I ensure it doesn’t hide while users interact with the header?
Add guards: if the header contains the active element, keep it visible. Optionally lock visibility for a short time after pointerdown/touchstart in the header region. Keep it simple and test with keyboard and screen readers.
9) What’s the safest failure mode?
Header stays visible. If the JS fails to load, throws an exception, or is blocked, the user should still have navigation. This is why CSS-first matters.
10) How do I measure success beyond “feels smooth”?
Track CLS and INP, plus user engagement signals like nav usage (carefully, without logging on scroll). If CLS rises after rollout, assume the header contributed until proven otherwise.
Conclusion: next steps you can actually execute
Build the sticky header like you build a reliable service: start with a stable baseline, add controlled behavior behind a small state machine, and keep a kill switch within reach. CSS gets you stability. Minimal JS gives you the one thing CSS can’t: direction.
Next steps:
- Audit your current header: if it changes height or display during scroll, fix that first.
- Add
:targetscroll margin (or heading-specific scroll margins) to stop anchor overlap. - Implement rAF-batched, passive scroll direction detection with thresholds; keep it under 50 lines.
- Add a sentinel for top-of-page elevation if mobile quirks bite you.
- Ship behind a feature flag, watch CLS/INP, and be ready to turn it off without redeploying.
When it works, nobody notices. That’s the point. A header is a tool, not a performance art piece.