interior[.]dev

Scroll·07.5

Hide on Scroll

Toolbar yields to the content.

Longitude

A ship at sea can read its latitude off the sun. Longitude is a question about time: how far local noon has drifted from noon at a port whose position is already known.

The pendulum clocks of the seventeenth century kept excellent time on land and none at all on a deck that pitched, rolled, and changed temperature twice a day.

John Harrison spent thirty-one years on it. The first three machines were large, ingenious, and impractical. The fourth was the size of a pocket watch.

H4 lost five seconds on the passage to Jamaica in 1761 — about a minute and a quarter of longitude, comfortably inside what the prize demanded.

The Board of Longitude paid him in instalments, over fourteen years, and never in full.

Within a generation the chronometer was ordinary. Ships carried three, because two that disagree tell you nothing at all.

hide-on-scroll

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { useState } from "react";
import { HideOnScroll } from "@/components/interior/hide-on-scroll";

export function ActivityFeed({ posts }: { posts: Post[] }) {
  const [filterOpen, setFilterOpen] = useState(false);

  return (
    <HideOnScroll
      label="Activity"
      maxHeight={640}
      barHeight={48}
      pinned={filterOpen}
      bar={
        <>
          <span className="min-w-0 flex-1 truncate text-[13px] font-medium">
            All activity
          </span>
          <button
            type="button"
            aria-expanded={filterOpen}
            onClick={() => setFilterOpen((open) => !open)}
            className="h-7 rounded-[6px] border border-stone-200 px-2 text-[12px] dark:border-white/[0.16]"
          >
            Filter
          </button>
        </>
      }
    >
      <ul className="divide-y divide-stone-200 dark:divide-white/10">
        {posts.map((post) => (
          <PostRow key={post.id} post={post} />
        ))}
      </ul>
    </HideOnScroll>
  );
}

Source

components/interior/hide-on-scroll.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

const DISCLOSE = { type: "spring", stiffness: 150, damping: 27, mass: 1 } as const;
const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;

export type UseHideOnScrollOptions = {
  hideAfter?: number;
  revealAfter?: number;
  topGuard?: number;
  pinned?: boolean;
  disabled?: boolean;
};

export type UseHideOnScrollResult<T extends HTMLElement> = {
  ref: React.RefObject<T | null>;
  hidden: boolean;
  atTop: boolean;
};

export function useHideOnScroll<T extends HTMLElement = HTMLDivElement>({
  hideAfter = 14,
  revealAfter = 10,
  topGuard = 24,
  pinned = false,
  disabled = false,
}: UseHideOnScrollOptions = {}): UseHideOnScrollResult<T> {
  const ref = useRef<T | null>(null);
  const frame = useRef(0);
  const last = useRef(0);
  const accum = useRef(0);

  const held = useRef(pinned || disabled);
  held.current = pinned || disabled;

  const [hidden, setHidden] = useState(false);
  const [atTop, setAtTop] = useState(true);

  const down = Math.max(1, hideAfter);
  const up = Math.max(1, revealAfter);
  const guard = Math.max(0, topGuard);

  useEffect(() => {
    if (!pinned && !disabled) return;
    accum.current = 0;
    setHidden(false);
  }, [pinned, disabled]);

  useEffect(() => {
    const el = ref.current;
    const target: EventTarget = el ?? window;

    const readY = () => (el ? el.scrollTop : window.scrollY);
    const readMax = () =>
      el
        ? el.scrollHeight - el.clientHeight
        : document.documentElement.scrollHeight - window.innerHeight;

    const evaluate = () => {
      frame.current = 0;

      const max = readMax();
      const y = readY();

      if (max <= guard) {
        accum.current = 0;
        last.current = y;
        setAtTop((prev) => (prev ? prev : true));
        setHidden((prev) => (prev ? false : prev));
        return;
      }

      if (y < 0 || y > max) return;

      const dy = y - last.current;
      last.current = y;

      const top = y <= guard;
      setAtTop((prev) => (prev === top ? prev : top));

      if (held.current || top) {
        accum.current = 0;
        setHidden((prev) => (prev ? false : prev));
        return;
      }

      if (dy === 0) return;
      if (dy > 0 !== accum.current > 0) accum.current = 0;
      accum.current += dy;

      if (accum.current >= down) {
        accum.current = 0;
        setHidden((prev) => (prev ? prev : true));
      } else if (accum.current <= -up) {
        accum.current = 0;
        setHidden((prev) => (prev ? false : prev));
      }
    };

    const schedule = () => {
      if (frame.current) return;
      frame.current = requestAnimationFrame(evaluate);
    };

    last.current = readY();
    evaluate();

    target.addEventListener("scroll", schedule, { passive: true });
    window.addEventListener("resize", schedule);

    let observer: ResizeObserver | null = null;
    if (el && typeof ResizeObserver !== "undefined") {
      observer = new ResizeObserver(schedule);
      observer.observe(el);
    }

    return () => {
      target.removeEventListener("scroll", schedule);
      window.removeEventListener("resize", schedule);
      observer?.disconnect();
      if (frame.current) cancelAnimationFrame(frame.current);
      frame.current = 0;
    };
  }, [down, up, guard]);

  return { ref, hidden, atTop };
}

export type HideOnScrollProps = {
  bar: React.ReactNode;
  children: React.ReactNode;
  barHeight?: number;
  hideAfter?: number;
  revealAfter?: number;
  topGuard?: number;
  pinned?: boolean;
  maxHeight?: number;
  label?: string;
  onHiddenChange?: (hidden: boolean) => void;
  className?: string;
};

export function HideOnScroll({
  bar,
  children,
  barHeight = 44,
  hideAfter = 14,
  revealAfter = 10,
  topGuard = 24,
  pinned = false,
  maxHeight = 320,
  label = "Scrollable content",
  onHiddenChange,
  className = "",
}: HideOnScrollProps) {
  const [focusWithin, setFocusWithin] = useState(false);

  const { ref, hidden, atTop } = useHideOnScroll<HTMLDivElement>({
    hideAfter,
    revealAfter,
    topGuard,
    pinned: pinned || focusWithin,
  });

  const reduced = useReducedMotion();
  const slide = reduced ? { duration: 0 } : DISCLOSE;
  const fade = reduced ? { duration: 0 } : CROSSFADE;

  const seen = useRef(hidden);
  useEffect(() => {
    if (seen.current === hidden) return;
    seen.current = hidden;
    onHiddenChange?.(hidden);
  }, [hidden, onHiddenChange]);

  return (
    <div
      className={`relative w-full min-w-0 overflow-hidden rounded-[14px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}
    >
      <motion.div
        data-hidden={hidden ? "true" : "false"}
        onFocus={() => setFocusWithin(true)}
        onBlur={() => setFocusWithin(false)}
        style={{ height: barHeight }}
        initial={false}
        animate={{ y: hidden ? -barHeight : 0 }}
        transition={slide}
        className="absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-white px-3 dark:bg-[#1D1D1A]"
      >
        {bar}

        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: atTop ? 0 : 1 }}
          transition={fade}
          className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-stone-200 dark:bg-white/[0.16]"
        />
      </motion.div>
      <div
        ref={ref}

        // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
        tabIndex={0}
        role="region"
        aria-label={label}
        style={{ maxHeight, scrollPaddingTop: barHeight + 8 }}
        className="overflow-y-auto overscroll-y-contain outline-none [scrollbar-gutter:stable] focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.06] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
      >
        <div aria-hidden style={{ height: barHeight }} />
        <div
          aria-hidden
          className="pointer-events-none sticky top-0 -mb-5 h-5 bg-gradient-to-b from-white to-transparent dark:from-[#1D1D1A]"
        />
        {children}
        <div
          aria-hidden
          className="pointer-events-none sticky bottom-0 -mt-5 h-5 bg-gradient-to-t from-white to-transparent dark:from-[#1D1D1A]"
        />
      </div>
    </div>
  );
}

Props

bar
React.ReactNode

Contents of the toolbar. Laid out in a flex row; give the title min-w-0 flex-1 truncate so long titles shrink instead of pushing the actions out.

children
React.ReactNode

The scrolling content. A spacer of barHeight is inserted above it so nothing starts underneath the bar.

barHeight44
number

Height of the bar in pixels, and the exact distance it travels when it yields.

hideAfter14
number

Pixels of uninterrupted downward travel required before the bar yields. Raise it if your surface has coarse momentum.

revealAfter10
number

Pixels of upward travel required to bring the bar back. Kept below hideAfter so returning is cheaper than leaving.

topGuard24
number

Distance from the top inside which the bar is always shown, so a short scroll never hides it a few pixels in.

pinnedfalse
boolean

Forces the bar open and holds it there. Set it while a menu, popover or search field the bar owns is on screen.

maxHeight320
number

Height cap on the scroll region. The component scrolls inside it rather than growing the page.

label"Scrollable content"
string

Accessible name for the scroll region, which is focusable so keyboard users can scroll it.

onHiddenChange
(hidden: boolean) => void

Called once per transition, never per frame. Use it to fade a floating action button in step with the bar.

className""
string

Appended last to the outer frame, so radius, border and background are overridable.