Skip to content

Hide On Scroll Navbar

A header that slips away as you scroll down and springs back the moment you scroll up. Clear at the top, frosted glass once you move, with a reading-progress line that stays visible as a hairline while the bar is away. Follows the page or any scroll box, no animation library.

DOM + CSS0 deps1 file · 7 KB TSXreduced-motion aware

Freeminimalglass
Preview

Props

Frosted glass after scroll
Progress line
40
4
No runtime dependencies
"use client";

import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { ReactNode, RefObject } from "react";

export type HideOnScrollNavbarProps = {
  /** The bar's own content (logo, links, a button). Defaults to a "Brand" wordmark. */
  children?: ReactNode;
  /**
   * What scrolls. "window" (default) follows the page scroll, which is what a
   * real site header wants; pass the ref of any overflow-y-auto element to
   * follow that box instead.
   */
  scroller?: RefObject<HTMLElement | null> | "window";
  /** "fixed" to the viewport or "absolute" inside the nearest positioned parent. Default: fixed for the window, absolute for a scroller box. */
  position?: "fixed" | "absolute";
  /** "light" paints a white bar with dark text, "dark" an ink bar with light text. Default "light". */
  tone?: "light" | "dark";
  /** Transparent at the very top, then frosted glass after the first scroll. false keeps a solid bar all the time. Default true. */
  glass?: boolean;
  /** Reading-progress line along the bottom edge (it stays visible as a hairline while the bar is away). Default false. */
  progress?: boolean;
  /** Colour of the progress line (any CSS colour). Default: the bar's text colour. */
  accent?: string;
  /** The bar never hides above this scroll offset, in px. Default 40. */
  hideAfter?: number;
  /** Scroll movement in px that counts as a change of direction. Absorbs trackpad jitter. Default 4. */
  sensitivity?: number;
  className?: string;
};

const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
const subscribeReduce = (cb: () => void) => {
  const m = window.matchMedia(REDUCE_QUERY);
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
};
const getReduce = () => window.matchMedia(REDUCE_QUERY).matches;
const getReduceServer = () => false;

const SPRING_IN = "transform 640ms cubic-bezier(0.3, 1.35, 0.5, 1)";
const EASE_OUT = "transform 300ms cubic-bezier(0.65, 0, 0.35, 1)";
const SURFACE_FADE = "background-color 320ms ease, box-shadow 320ms ease, backdrop-filter 320ms ease, -webkit-backdrop-filter 320ms ease";
const COLOUR = /^[#a-zA-Z0-9(),.%\s/-]{1,48}$/;

const TONES = {
  light: {
    fg: "#0b0c0f",
    glass: "rgba(255,255,255,0.72)",
    solid: "rgba(255,255,255,0.92)",
    hair: "rgba(11,12,15,0.09)",
    lift: "0 10px 28px -14px rgba(11,12,15,0.28)",
  },
  dark: {
    fg: "#f4f5f7",
    glass: "rgba(10,11,14,0.58)",
    solid: "rgba(10,11,14,0.9)",
    hair: "rgba(255,255,255,0.09)",
    lift: "0 14px 34px -14px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.05)",
  },
} as const;

export function HideOnScrollNavbar({
  children,
  scroller = "window",
  position,
  tone = "light",
  glass = true,
  progress = false,
  accent,
  hideAfter = 40,
  sensitivity = 4,
  className = "",
}: HideOnScrollNavbarProps) {
  const rootRef = useRef<HTMLElement>(null);
  const lineRef = useRef<HTMLDivElement>(null);
  const [hidden, setHidden] = useState(false);
  const [scrolled, setScrolled] = useState(false);
  const reduce = useSyncExternalStore(subscribeReduce, getReduce, getReduceServer);

  useEffect(() => {
    const el = scroller === "window" ? null : scroller.current;
    if (scroller !== "window" && !el) return;
    const target: HTMLElement | Window = el ?? window;
    const read = () => {
      if (el) return { y: el.scrollTop, max: el.scrollHeight - el.clientHeight };
      return { y: window.scrollY, max: document.documentElement.scrollHeight - window.innerHeight };
    };
    let last = read().y;
    let isHidden = false;
    let isScrolled = false;
    let raf = 0;
    const frame = () => {
      raf = 0;
      const { y: raw, max } = read();
      // Clamp so rubber-band overscroll never reads as a direction change.
      const y = Math.min(Math.max(raw, 0), Math.max(max, 0));
      if (lineRef.current) lineRef.current.style.transform = "scaleX(" + (max > 0 ? y / max : 0).toFixed(4) + ")";
      const sc = y > 8;
      if (sc !== isScrolled) {
        isScrolled = sc;
        setScrolled(sc);
      }
      let next = isHidden;
      if (y <= hideAfter) {
        next = false;
        last = y;
      } else if (Math.abs(y - last) > sensitivity) {
        next = y > last;
        last = y;
      }
      if (next !== isHidden) {
        isHidden = next;
        setHidden(next);
      }
    };
    const schedule = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    target.addEventListener("scroll", schedule, { passive: true });
    window.addEventListener("resize", schedule);
    schedule();
    return () => {
      target.removeEventListener("scroll", schedule);
      window.removeEventListener("resize", schedule);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [scroller, hideAfter, sensitivity]);

  // Once it has slid away, take it out of the tab order and the accessibility tree.
  useEffect(() => {
    const node = rootRef.current;
    if (!node) return;
    if (!hidden) {
      node.toggleAttribute("inert", false);
      return;
    }
    const t = window.setTimeout(() => node.toggleAttribute("inert", true), reduce ? 0 : 300);
    return () => window.clearTimeout(t);
  }, [hidden, reduce]);

  const t = TONES[tone === "dark" ? "dark" : "light"];
  const raised = glass ? scrolled && !hidden : true;
  const bg = glass ? (scrolled ? t.glass : "transparent") : t.solid;
  const blur = glass ? (scrolled ? "blur(16px) saturate(1.7)" : "blur(0px)") : "blur(6px)";
  const pos = position ?? (scroller === "window" ? "fixed" : "absolute");
  const line = accent && COLOUR.test(accent) ? accent : "currentColor";
  const peek = progress ? "calc(-100% + 2px)" : "-100%";

  return (
    <header
      ref={rootRef}
      className={
        (pos === "fixed" ? "fixed" : "absolute") + " inset-x-0 top-0 z-40 flex items-center justify-between px-6 py-3 " + className
      }
      style={{
        color: t.fg,
        backgroundColor: bg,
        backdropFilter: blur,
        WebkitBackdropFilter: blur,
        boxShadow: hidden ? "none" : (raised ? t.lift + ", " : "") + "0 1px 0 " + (glass && !scrolled ? "transparent" : t.hair),
        transform: hidden && !reduce ? "translateY(" + peek + ")" : "translateY(0)",
        opacity: hidden && reduce ? 0 : 1,
        pointerEvents: hidden ? "none" : undefined,
        transition: reduce
          ? "opacity 160ms ease, " + SURFACE_FADE
          : (hidden ? EASE_OUT : SPRING_IN) + ", opacity 160ms ease, " + SURFACE_FADE,
        willChange: "transform",
      }}
    >
      {/* Fills the gap above the bar while the spring overshoots. */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 bottom-full h-4"
        style={{ backgroundColor: bg, backdropFilter: blur, WebkitBackdropFilter: blur }}
      />
      {children ?? <span className="font-bold">Brand</span>}
      {progress ? (
        <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden">
          <div
            ref={lineRef}
            className="h-full w-full origin-left"
            style={{ background: line, opacity: 0.85, transform: "scaleX(0)", willChange: "transform" }}
          />
        </div>
      ) : null}
    </header>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

The pattern reclaims vertical space without losing navigation: the bar slides away while you read down the page and springs back the instant you scroll up, which usually means you are hunting for something. Reach for it on long, content-dense pages where a permanent header competes with the content; use a plain sticky bar where navigation must always be one glance away, such as a checkout. Direction comes from comparing the scroll offset against the last accepted offset, inside one passive scroll listener coalesced to a single requestAnimationFrame, with no React render per scroll tick; state changes only when the bar actually needs to move. The sensitivity prop (4px by default) absorbs trackpad and momentum jitter, hideAfter (40px) keeps the bar in place near the top, and the offset is clamped to the scrollable range so rubber-band overscroll on touch devices never reads as a direction change. At the very top the bar is transparent; after the first few pixels of scroll it fades to frosted glass (a blurred, saturated, tinted surface with a soft lift shadow), or stays a solid bar if you set glass to false. Hiding uses a 300ms ease-in-out slide; revealing uses a 640ms spring-style curve with a few pixels of overshoot, and a small filler above the bar hides the gap while it overshoots. With progress on (it is off by default; the preview turns it on), a 2px reading-progress line grows along the bottom edge (written straight to the DOM, scaleX from 0 to 1), and it stays visible as a hairline at the top of the screen while the bar is away, so you always see how far down you are. By default the scroller is the window and the bar is fixed to the viewport; pass scroller as the ref of any overflow-y-auto element and it switches to absolute positioning inside that box's positioned parent, which is how the preview works. tone switches between a white bar with dark text (the default, as before) and an ink bar with light text, and children takes any content you like.

Curator’s note

Rebuilt in October without framer-motion: direction detection is a dozen lines of hand-written scroll logic, so the component is React and Tailwind only. The preview runs it inside its own scroll box on a mock landing page; in your project it follows the window by default.

Pairs well with

Holo Pill Nav

components
Pro