## 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 } > ); } ``` ### 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 (
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}
{children}
); } ```