TL;DR Store the last scrollY. In a passive scroll listener throttled with requestAnimationFrame, hide the header with -translate-y-full when the page moves down more than about 10px and show it when it moves up. Clamp scrollY for Safari's bounce, always show it near the top, keep it visible while anything inside has focus, and add scroll-padding-top so anchor links land below it.
A header that slides away when you scroll down and comes back the moment you scroll up gives the page back about 64 pixels on a phone without hiding the navigation. In React it's one small hook and one class swap. The parts that take care are the ones AI editors usually skip: a threshold so it doesn't flicker, Safari's bounce at the top, keyboard focus, and anchor links that land under the header.
I make the Dynamic Island Navbar piece for qodesign, a black pill navbar that morphs into a notification, a music player and search. The piece uses Framer Motion. The code in this post uses no library, only React, Next.js and Tailwind. All three files compile with strict tsc against the React 19 and Next 15 types in our own app, and I ran the scroll logic in Node against a list of scroll positions, including the negative and past-the-end values Safari can report.
Keep the last scroll position. On each frame where the page scrolled, compare the new position with it:
Near the top of the page, always show the header.
If the page moved less than a threshold (10px here), do nothing and keep the old position, so slow scrolling adds up until it crosses the threshold.
Moved down by more than the threshold: hide. Moved up: show.
Then the header gets -translate-y-full when it's hidden and translate-y-0 when it's shown, with a 300ms transform transition. A transform doesn't change layout, so the page under the header doesn't jump.
Dynamic Island Navbar running live. A pill navbar that morphs for notifications, music and search.
The decision is a pure function, nextScrollState, so you can test it without a browser. The hook only reads the numbers and stores the result.
ts
"use client";import { useEffect, useRef, useState } from "react";export type ScrollState = { hidden: boolean; lastY: number };// Pure: given the last state and the new scroll position, decide what to show.export function nextScrollState( prev: ScrollState, rawY: number, maxY: number, threshold: number, topOffset: number,): ScrollState { // Safari can report scrollY below 0 or past the end while it bounces. const y = Math.min(Math.max(rawY, 0), Math.max(maxY, 0)); if (y <= topOffset) return { hidden: false, lastY: y }; const delta = y - prev.lastY; // Small moves add up: lastY only moves once we pass the threshold. if (Math.abs(delta) < threshold) return prev; return { hidden: delta > 0, lastY: y };}export function useHideOnScroll(threshold = 10, topOffset = 64): boolean { const [hidden, setHidden] = useState(false); const state = useRef<ScrollState>({ hidden: false, lastY: 0 }); useEffect(() => { state.current = { hidden: false, lastY: window.scrollY }; let frame = 0; const update = () => { frame = 0; const maxY = document.documentElement.scrollHeight - window.innerHeight; const next = nextScrollState(state.current, window.scrollY, maxY, threshold, topOffset); state.current = next; setHidden(next.hidden); // same value as before: React skips the render }; const onScroll = () => { if (frame) return; // one update per frame, however many scroll events fire frame = window.requestAnimationFrame(update); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => { window.removeEventListener("scroll", onScroll); if (frame) window.cancelAnimationFrame(frame); }; }, [threshold, topOffset]); return hidden;}
A few choices in there, with where they come from:
{ passive: true } tells the browser the handler will never call preventDefault(), as MDN's addEventListener page describes it. This handler only reads a number, so the promise costs nothing.
requestAnimationFrame runs the callback before the next repaint, and MDN notes it usually runs at the display's refresh rate. The frame check means at most one update per frame, however many scroll events arrive.
Calling setHidden with the same value is cheap. The useState reference says React skips re-rendering when the new value is identical to the current state, compared with Object.is.
The clamp. MDN's scrollY page says Safari updates scrollY beyond the maximum scroll position when you overscroll, and that it can be negative if you keep scrolling up at the top. Without the clamp, the bounce at the bottom of a page reads as a scroll up and the header pops back in. scrollY can also have a decimal part, which the threshold absorbs.
"use client";import Link from "next/link";import { useState, type FocusEvent } from "react";import { useHideOnScroll } from "./useHideOnScroll";const links = [ { href: "/docs", label: "Docs" }, { href: "/pricing", label: "Pricing" }, { href: "/blog", label: "Blog" },];export function SiteHeader() { const hidden = useHideOnScroll(); const [hasFocus, setHasFocus] = useState(false); const show = !hidden || hasFocus; const onBlur = (e: FocusEvent<HTMLElement>) => { // Only drop the pin when focus leaves the header, not when it moves inside it. if (!e.currentTarget.contains(e.relatedTarget)) setHasFocus(false); }; return ( <header onFocus={() => setHasFocus(true)} onBlur={onBlur} className={`sticky top-0 z-40 border-b border-zinc-200 bg-white/80 backdrop-blur transition-transform duration-300 ease-out motion-reduce:transition-none ${ show ? "translate-y-0" : "-translate-y-full" }`} > <nav className="mx-auto flex h-16 max-w-5xl items-center gap-6 px-4"> <Link href="/" className="font-semibold"> acme </Link> {links.map((l) => ( <Link key={l.href} href={l.href} className="text-sm text-zinc-600 hover:text-zinc-900"> {l.label} </Link> ))} </nav> </header> );}
onFocus in React bubbles, so the header hears it when any link inside gets focus. That's the keyboard fix. If someone tabs into a hidden header, it slides back in and stays until focus leaves it. WCAG 2.2 success criterion 2.4.11, Focus Not Obscured (Minimum), says a focused component must not be entirely hidden by author-created content, and it names sticky headers as a typical cause. Here the header itself would be the hidden thing, which is just as bad for a keyboard user.
motion-reduce:transition-none keeps the hide and show but drops the slide for people who ask for less motion. More on that in the prefers-reduced-motion post.
The header stays a Client Component. The root layout that uses it can stay a Server Component.
tsx
import type { ReactNode } from "react";import { SiteHeader } from "./SiteHeader";export default function RootLayout({ children }: { children: ReactNode }) { return ( // scroll-pt-20: anchor links land below the 64px header, with a little air <html lang="en" className="scroll-pt-20"> <body> <SiteHeader /> <main>{children}</main> </body> </html> );}
scroll-pt-20 sets scroll-padding-top on the page. MDN describes that property as a way to exclude parts of the scroll area that are covered by fixed toolbars when the browser scrolls something into view, so #pricing lands below the header instead of under it. Tailwind's scroll-padding page has the scroll-pt-* classes. Use a value a bit larger than the header height.
sticky top-0 keeps the header in the normal flow at the top of the page, so you don't need padding on <main> to make room for it. A translated sticky header still slides out of view the same way a fixed one does.
The catch: a sticky element sticks to its nearest ancestor with a scrolling mechanism. MDN's position page lists overflow: hidden, scroll, auto and overlay as what creates one, even when that ancestor isn't the one actually scrolling. So an overflow-x-hidden wrapper around your layout, added to stop sideways scroll on mobile, can quietly stop the header from sticking. If the header scrolls away with the page, look up the tree for an overflow class. The scroll container post covers the other scroll traps.
When Cursor, v0 or Lovable writes this for you, these are the things I'd check before shipping:
Does it call setState straight from the scroll listener, with no requestAnimationFrame and no threshold? It will flicker on trackpads.
Does the header pop back in when you bounce at the bottom of a page in Safari on iPhone? Clamp scrollY.
Can you Tab into the nav while it's hidden and see where focus is?
Do anchor links land under the header? Add scroll-padding-top.
Does it animate top or height instead of transform? That moves the layout under it.
A prompt that covers most of it: "Write a React hook that hides a sticky header on scroll down and shows it on scroll up. Use a passive scroll listener with requestAnimationFrame, a 10px threshold, clamp scrollY between 0 and the max scroll, and always show it near the top. Animate translateY only. Show the header when anything inside it has focus. Add scroll-padding-top to html."
The same hook works for any header, including a pill. The Dynamic Island Navbar piece is $3 and morphs one black pill into a notification, a music player and search with spring transitions. There's an overview with props at Dynamic Island navbar for React. If your app is mostly used on phones, a bottom tab bar is another option, and the animated tab bar post builds one for the App Router. The free pieces show how the paid code is written before you buy anything.
Store the last window.scrollY. In a passive scroll listener throttled with requestAnimationFrame, compare the new position with it. If it moved down by more than about 10px, hide the header with translate-y-full. If it moved up, show it. Always show it near the top of the page.
Usually there's no threshold, so every tiny scroll change flips the state. Ignore moves smaller than about 10px and only update the stored position once a move passes the threshold. Bouncing at the top or bottom in Safari can also flip it, so clamp scrollY between 0 and the maximum scroll.
Sticky is simpler because it keeps its own space at the top of the page. It stops working if an ancestor has overflow set to hidden, scroll or auto, so check your layout wrappers. Fixed always works but needs padding on the content below it.
It can if a keyboard user tabs into links they can't see. Show the header whenever anything inside it has focus, keep the slide off for prefers-reduced-motion, and set scroll-padding-top so in-page links don't land under it.