My fancy animated navbar featured here https://bsky.app/profile/hipstersmoothie.com/post/3m77zj5ouxk2b
useAnimatedNavbar.tsx edited
156 lines 4.3 kB view raw
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};