## Hide on Scroll — Scroll
Toolbar yields to the content.
Docs: https://www.interior.dev/docs/hide-on-scroll
Reference: https://www.interior.dev/reference/hide-on-scroll
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/hide-on-scroll.json`
Or run `bun add motion` and copy the source below.
### Usage
```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 (
All activity
>
}
>
{posts.map((post) => (
))}
);
}
```
### 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.
- `barHeight` (`number`) — default: `44`
Height of the bar in pixels, and the exact distance it travels when it yields.
- `hideAfter` (`number`) — default: `14`
Pixels of uninterrupted downward travel required before the bar yields. Raise it if your surface has coarse momentum.
- `revealAfter` (`number`) — default: `10`
Pixels of upward travel required to bring the bar back. Kept below hideAfter so returning is cheaper than leaving.
- `topGuard` (`number`) — default: `24`
Distance from the top inside which the bar is always shown, so a short scroll never hides it a few pixels in.
- `pinned` (`boolean`) — default: `false`
Forces the bar open and holds it there. Set it while a menu, popover or search field the bar owns is on screen.
- `maxHeight` (`number`) — default: `320`
Height cap on the scroll region. The component scrolls inside it rather than growing the page.
- `label` (`string`) — default: `"Scrollable content"`
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`) — default: `""`
Appended last to the outer frame, so radius, border and background are overridable.
### Behavior notes
- Direction is decided by accumulated travel, not by the sign of the last scroll event, so a two-pixel trackpad wobble or a momentum stutter cannot flap the bar open and shut while someone reads.
- The bar leaves on a transform above a spacer that permanently reserves its height, so no line of content reflows when it yields or returns.
- Overscroll past either end is discarded rather than read as a direction change, so a rubber-band bounce at the bottom of an iOS list does not hide the bar.
- Focus entering the bar pins it open before the control lands, and `pinned` holds it open for a menu the bar owns, so a focusable action is never parked off-screen.
- Inside the top guard, and whenever the content is too short to scroll, the bar is unconditionally shown; a resize or a shrinking list cannot strand it above the frame.
- Scroll reads are coalesced into one animation frame and collapse to a single boolean, so React renders on the transition rather than per frame, and under `prefers-reduced-motion` the bar changes place without the trip.
### Source (`components/interior/hide-on-scroll.tsx`)
```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 = {
ref: React.RefObject;
hidden: boolean;
atTop: boolean;
};
export function useHideOnScroll({
hideAfter = 14,
revealAfter = 10,
topGuard = 24,
pinned = false,
disabled = false,
}: UseHideOnScrollOptions = {}): UseHideOnScrollResult {
const ref = useRef(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({
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 (