useAnimatedNavbar.tsx
edited
1"use client";
2
3import * as stylex from "@stylexjs/stylex";
4import rafThrottle from "raf-throttle";
5import { useEffect, useRef, useState } from "react";
6
7import {
8 animationDuration,
9 animationTimingFunction,
10} from "../theme/animations.stylex";
11
12const SCROLL_THRESHOLD = 16;
13
14const styles = stylex.create({
15 navbarOutOfViewport: {
16 position: "sticky",
17 transform: "translateY(-100%)",
18 top: 0,
19 },
20 navbarRevealed: {
21 position: "sticky",
22 transform: "translateY(0)",
23 transitionDuration: animationDuration.default,
24 transitionProperty: "transform",
25 transitionTimingFunction: animationTimingFunction.easeInOut,
26 top: 0,
27 },
28 navbarAnimatedOut: {
29 transitionDuration: animationDuration.default,
30 transitionProperty: "transform",
31 transitionTimingFunction: animationTimingFunction.easeInOut,
32 },
33});
34
35/**
36 * A hook that animates the navbar into view and stick to the top when the user scrolls down.
37 */
38export const useAnimatedNavbar = ({
39 scrollContainer: scrollContainerProp,
40}: {
41 scrollContainer?: React.RefObject<HTMLElement | null>;
42}) => {
43 const lastScrollY = useRef(0);
44 const [hasScrollNavbarOutOfView, setHasScrollNavbarOutOfView] =
45 useState(false);
46 const [shouldAnimateOut, setShouldAnimateOut] = useState(false);
47 const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
48 const navRef = useRef<HTMLElement>(null);
49 const topSentinelRef = useRef<HTMLDivElement>(null);
50
51 // Use intersection observer to detect when navbar is out of viewport
52 useEffect(() => {
53 if (!navRef.current) return;
54
55 const observer = new IntersectionObserver(([entry]) => {
56 if (!entry || entry.isIntersecting) return;
57 setHasScrollNavbarOutOfView(true);
58 });
59
60 observer.observe(navRef.current);
61
62 return () => {
63 observer.disconnect();
64 };
65 }, []);
66
67 // Animate the navbar into view and stick to the top
68 useEffect(() => {
69 if (!hasScrollNavbarOutOfView || !scrollContainerProp?.current) return;
70
71 const handleScroll = rafThrottle((e: Event) => {
72 if (!(e.target instanceof HTMLElement)) return;
73
74 const currentScrollY = e.target.scrollTop;
75 const scrollDirection =
76 currentScrollY > lastScrollY.current ? "down" : "up";
77
78 if (scrollDirection === "up") {
79 // Only hide/show if scrolled past threshold
80 if (Math.abs(currentScrollY - lastScrollY.current) < SCROLL_THRESHOLD) {
81 return;
82 }
83
84 // Show navbar when scrolling up or at the top
85 if (
86 currentScrollY < lastScrollY.current ||
87 currentScrollY <= SCROLL_THRESHOLD
88 ) {
89 setShouldAnimateIn(true);
90 }
91 }
92 // Animate navbar out when scrolling down past threshold
93 else if (
94 currentScrollY > lastScrollY.current &&
95 currentScrollY > SCROLL_THRESHOLD
96 ) {
97 setShouldAnimateIn(false);
98 setShouldAnimateOut(true);
99 }
100
101 lastScrollY.current = currentScrollY;
102 });
103
104 const scrollContainer = scrollContainerProp.current;
105
106 scrollContainer.addEventListener("scroll", handleScroll, {
107 passive: true,
108 });
109
110 return () => {
111 scrollContainer.removeEventListener("scroll", handleScroll);
112 };
113 }, [hasScrollNavbarOutOfView, scrollContainerProp]);
114
115 // Use IntersectionObserver to detect if scrolled to top (most performant)
116 useEffect(() => {
117 if (!topSentinelRef.current || !scrollContainerProp?.current) return;
118
119 const observer = new IntersectionObserver(
120 ([entry]) => {
121 if (!entry) return;
122
123 const atTop = entry.isIntersecting;
124
125 setHasScrollNavbarOutOfView((has) => {
126 if (!has) return has;
127 if (atTop) {
128 setShouldAnimateIn(false);
129 setShouldAnimateOut(false);
130 return false;
131 }
132 return true;
133 });
134 },
135 { root: scrollContainerProp.current },
136 );
137
138 observer.observe(topSentinelRef.current);
139
140 return () => {
141 observer.disconnect();
142 };
143 }, [scrollContainerProp]);
144
145 return {
146 sentinel: <div ref={topSentinelRef} />,
147 navBarProps: {
148 ref: navRef,
149 style: [
150 hasScrollNavbarOutOfView && styles.navbarOutOfViewport,
151 shouldAnimateIn && styles.navbarRevealed,
152 shouldAnimateOut && styles.navbarAnimatedOut,
153 ],
154 },
155 };
156};