Skip to the content

Library/ 15 sticky-headerHeaders

Sticky Header

A top bar that steps aside while you read.

Scroll inside the preview

Settings

8
0
300

What it does

Sticky Header is a header animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called hide on scroll, smart navbar or headroom.

Scroll down and the header slides up out of the way, leaving the whole screen to the page. Scroll up a little and it drops right back. Tabbing into it with the keyboard always brings it into view.

Documentation

One copy gives an assistant everything it needs: the complete code, every option and every pitfall. Paste it in Claude, Cursor or ChatGPT and say where the effect goes; it adapts the integration to your stack, not the code.

  1. Copy the prompt

  2. Ask for it

    For instance: Add this sticky header effect to the hero of my home page.
    What it sends
    Markdown
    # Foundry44 · Sticky Header
    
    Header hides on scroll down and slides back on scroll up.
    
    Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.
    
    ## Markup it expects
    
    The element marked `data-f44="sticky-header"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `stickyHeader()` or as props.
    
    ```html
    <header data-f44="sticky-header">
      <span>Foundry Studio</span>
      <nav>
        <a href="#">Gardens</a>
        <a href="#">Studio</a>
        <a href="#">Journal</a>
        <!-- as many more as you need, same structure -->
      </nav>
      <span>Book a walk</span>
    </header>
    ```
    
    ## Options
    
    | Option | Attribute | Type | Default | Description |
    |---|---|---|---|---|
    | `tolerance` | `data-f44-tolerance` | `number` | `8` | Pixels scrolled in one direction before the header hides or comes back. Ignores small jitters. |
    | `offset` | `data-f44-offset` | `number` | `0` | Extra pixels below the header's own height within which it always shows. |
    | `duration` | `data-f44-duration` | `number` | `300` | Time in ms for the header to slide out. Coming back takes a little less, so it answers quickly. |
    
    ## Setup
    
    - **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/sticky-header/sticky-header.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="sticky-header"` element and reads the `data-f44-*` attributes above.
    - **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/sticky-header.json` writes `lib/sticky-header-core.ts` and `components/ui/sticky-header.tsx`, then use `<StickyHeader />` with the options as props.
    - **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { StickyHeader } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { stickyHeader } from "@foundry44/core/sticky-header"`.
    - **React / Next.js by hand**: save the code below as `lib/sticky-header-core.ts` and import it from there (`@/lib/sticky-header-core` where that alias exists). Don't name it `sticky-header.ts` next to a `StickyHeader.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `stickyHeader(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
    - **Any other stack**: create on mount with `stickyHeader(element, options)`, `update(options)` when they change, `destroy()` on unmount.
    
    Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `stickyHeader(element)` on each element.
    
    ## Pitfalls
    
    - Respects prefers-reduced-motion: keep that behaviour.
    - A static element is made `position: sticky; top: 0`. Sticky only holds inside its parent: put the header directly in `<body>` (or a full-height wrapper), or make it `position: fixed` yourself.
    - Give the header a background and a `z-index`, otherwise the page shows through or slides over it.
    - It listens to the window's scroll. A header inside its own scrolling container is not handled.
    - Style the scrolled state with `[data-f44-scrolled]`, for example a shadow; it appears once the page has scrolled past the header.
    
    ## Code: `sticky-header-core.ts`
    
    ```ts
    // Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
    /**
     * Foundry44 — sticky-header
     * A header that stays at the top of the page, slides up out of view while the
     * visitor scrolls down to read, and slides back as soon as they scroll up.
     * Near the top of the page it always shows. Once the page has scrolled past it,
     * the element carries `data-f44-scrolled`, a hook for a shadow or a background.
     *
     * Self-contained: no imports, so this file can be copied as-is.
     */
    
    export interface StickyHeaderOptions {
      /** Pixels scrolled in one direction before the header hides or comes back. Ignores small jitters. @range 0 64 1 */
      tolerance?: number;
      /** Extra pixels below the header's own height within which it always shows. @range 0 400 10 */
      offset?: number;
      /** Time in ms for the header to slide out. Coming back takes a little less, so it answers quickly. @range 100 1000 25 */
      duration?: number;
    }
    
    export interface StickyHeaderInstance {
      update(options: StickyHeaderOptions): void;
      destroy(): void;
    }
    
    export const stickyHeaderDefaults: Required<StickyHeaderOptions> = {
      tolerance: 8,
      offset: 0,
      duration: 300,
    };
    
    const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
    const SHOW_RATIO = 0.75;
    const REDUCED_FADE = 200;
    
    export function stickyHeader(el: HTMLElement, options: StickyHeaderOptions = {}): StickyHeaderInstance {
      let opts = { ...stickyHeaderDefaults, ...options };
    
      if (typeof window === "undefined") return { update() {}, destroy() {} };
      // Reduced motion: no sliding, the header fades out and back in place.
      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
    
      const original = {
        position: el.style.position,
        top: el.style.top,
        transform: el.style.transform,
        opacity: el.style.opacity,
        transition: el.style.transition,
      };
      const computed = getComputedStyle(el);
      if (computed.position === "static") {
        el.style.position = "sticky";
        el.style.top = "0px";
      }
    
      let hidden = false;
      let lastY = window.scrollY;
      let travelledDown = 0;
      let travelledUp = 0;
    
      const transition = () => {
        const hide = opts.duration;
        const show = Math.round(opts.duration * SHOW_RATIO);
        if (reduced) {
          // Move it out of the way only once it has faded, and back before it fades in.
          el.style.transition = hidden
            ? `opacity ${REDUCED_FADE}ms ease, transform 0s linear ${REDUCED_FADE}ms`
            : `opacity ${REDUCED_FADE}ms ease`;
          return;
        }
        el.style.transition = `transform ${hidden ? hide : show}ms ${EASE_OUT}`;
      };
    
      const setHidden = (next: boolean) => {
        if (next === hidden) return;
        hidden = next;
        transition();
        // 100% plus a few px so a box-shadow below the header leaves with it.
        el.style.transform = hidden ? "translateY(calc(-100% - 8px))" : original.transform;
        if (reduced) el.style.opacity = hidden ? "0" : original.opacity;
      };
    
      const onScroll = () => {
        const y = window.scrollY;
        const delta = y - lastY;
        lastY = y;
        const alwaysVisible = el.offsetHeight + opts.offset;
        el.toggleAttribute("data-f44-scrolled", y > el.offsetHeight);
    
        if (y <= alwaysVisible) {
          travelledDown = travelledUp = 0;
          setHidden(false);
          return;
        }
        if (delta > 0) {
          travelledDown += delta;
          travelledUp = 0;
        } else if (delta < 0) {
          travelledUp -= delta;
          travelledDown = 0;
        }
        if (travelledDown > opts.tolerance) setHidden(true);
        else if (travelledUp > opts.tolerance) setHidden(false);
      };
    
      // A keyboard user tabbing into a hidden header must be able to see where focus went.
      const onFocusIn = () => setHidden(false);
    
      window.addEventListener("scroll", onScroll, { passive: true });
      el.addEventListener("focusin", onFocusIn);
      onScroll();
    
      return {
        update(next) {
          opts = { ...opts, ...next };
          // tolerance and offset are read on every scroll; duration needs the transition rewritten.
          transition();
        },
        destroy() {
          window.removeEventListener("scroll", onScroll);
          el.removeEventListener("focusin", onFocusIn);
          el.removeAttribute("data-f44-scrolled");
          el.style.position = original.position;
          el.style.top = original.top;
          el.style.transform = original.transform;
          el.style.opacity = original.opacity;
          el.style.transition = original.transition;
        },
      };
    }
    ```