Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | 4x 4x 51x 51x 51x 36x 25x 25x 11x 11x 10x 10x 9x 9x 9x 6x 6x 3x 1x 1x 10x 10x 51x | import {
type ReactNode,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils";
/**
* `ScrollHideHeader` — specified in the approved mobile spec
* (`docs/wip/specs/2026-03-31-portal-mobile-ux-redesign.md` §2: "Hides on
* scroll down, reappears on scroll up") and still unbuilt until now. The
* numbers below come from the signed-off mockup `.context/mockups/mobile.html`,
* which animates the behaviour rather than annotating it.
*
* Two rules the mockup is explicit about:
*
* - **Margin, not transform.** The bar collapses by pulling its own top margin
* negative, so the content genuinely reclaims the space instead of sliding
* under a hole. The old mobile header used `-translate-y-full` and left a
* 48px gap behind.
* - **The bottom nav does NOT hide.** It is the only way out of a screen. Only
* the header is hideable, and only because nothing in it is load-bearing —
* a `MobileFullPage` header carries the ✕ and the step count, so it stays
* pinned (pass `enabled={false}`).
*/
/** Don't hide while the user is still near the top of the page. */
export const SCROLL_HIDE_FLOOR = 56;
/**
* Collapsing the header reflows the content, which fires another scroll event.
* Without a dead zone the bar flickers.
*/
export const SCROLL_HIDE_HYSTERESIS = 8;
export type ScrollHideHeaderProps = {
/** The element that actually scrolls — usually `<main>`. */
scrollRef: RefObject<HTMLElement | null>;
/** Off on desktop and on full pages: the bar then never moves. */
enabled?: boolean;
className?: string;
/** How far up it tucks. Must match the bar's own height. */
collapsedClassName?: string;
children: ReactNode;
};
export function ScrollHideHeader({
scrollRef,
enabled = true,
className,
collapsedClassName = "-mt-12",
children,
}: ScrollHideHeaderProps) {
const [hidden, setHidden] = useState(false);
const lastY = useRef(0);
useEffect(() => {
if (!enabled) {
setHidden(false);
return;
}
const element = scrollRef.current;
if (!element) return;
lastY.current = element.scrollTop;
const onScroll = () => {
const y = element.scrollTop;
const delta = y - lastY.current;
if (delta > SCROLL_HIDE_HYSTERESIS && y > SCROLL_HIDE_FLOOR) {
setHidden(true);
lastY.current = y;
} else if (delta < -SCROLL_HIDE_HYSTERESIS) {
setHidden(false);
lastY.current = y;
}
};
element.addEventListener("scroll", onScroll, { passive: true });
return () => element.removeEventListener("scroll", onScroll);
}, [enabled, scrollRef]);
return (
<header
data-hidden={hidden ? "true" : undefined}
className={cn(
// Nothing moves under prefers-reduced-motion: it snaps instead.
"transition-[margin] duration-200 ease-out motion-reduce:transition-none",
hidden && collapsedClassName,
className,
)}
>
{children}
</header>
);
}
|