1- import { useEffect , useMemo , useState } from "react" ;
1+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
22import { APPS } from "./data/apps" ;
33import {
44 CATEGORY_LABELS ,
@@ -27,8 +27,38 @@ import { FooterCta } from "./components/footer-cta";
2727import { Card } from "./components/ui/card" ;
2828import { assetPath } from "./lib/assets" ;
2929
30+ // Track scroll progress from 0 to 1 over a given pixel distance
31+ // Uses requestAnimationFrame to avoid jank
32+ function useScrollProgress ( thresholdPx = 200 ) {
33+ const [ progress , setProgress ] = useState ( 0 ) ;
34+ const tickingRef = useRef ( false ) ;
35+
36+ const handler = useCallback ( ( ) => {
37+ if ( tickingRef . current ) return ;
38+ tickingRef . current = true ;
39+ requestAnimationFrame ( ( ) => {
40+ const scrollY = window . scrollY || document . documentElement . scrollTop ;
41+ setProgress ( Math . min ( 1 , Math . max ( 0 , scrollY / thresholdPx ) ) ) ;
42+ tickingRef . current = false ;
43+ } ) ;
44+ } , [ thresholdPx ] ) ;
45+
46+ useEffect ( ( ) => {
47+ window . addEventListener ( "scroll" , handler , { passive : true } ) ;
48+ return ( ) => window . removeEventListener ( "scroll" , handler ) ;
49+ } , [ handler ] ) ;
50+
51+ return progress ;
52+ }
53+
54+ /** Interpolate between two values based on progress (0..1) */
55+ function lerp ( a : number , b : number , t : number ) : number {
56+ return a + ( b - a ) * t ;
57+ }
58+
3059function App ( ) {
3160 const [ filters , setFilters ] = useState < DiscoverFilters > ( ( ) => parseFiltersFromSearch ( window . location . search ) ) ;
61+ const scrollProgress = useScrollProgress ( ) ;
3262
3363 useEffect ( ( ) => {
3464 document . title = "Bitcoin Apps Directory" ;
@@ -52,17 +82,60 @@ function App() {
5282
5383 const searching = filters . q . trim ( ) . length > 0 ;
5484
85+ // --- Collapsible header state ---
86+ const isCollapsed = scrollProgress >= 0.95 ;
87+ const bannerOpacity = Math . max ( 0 , 1 - scrollProgress * 2.5 ) ;
88+ const titleScale = lerp ( 1 , 0.65 , scrollProgress ) ;
89+ const subtitleFade = Math . max ( 0 , 1 - scrollProgress * 2 ) ;
90+ const titleYOffset = lerp ( 0 , 8 , scrollProgress ) ;
91+
5592 return (
5693 < main className = "bg-white" >
57- < section >
58- < div className = "discover-entry-image mx-auto mb-8 max-w-discover px-4 lg:px-0" >
59- < img src = { assetPath ( "images/discover/top-background.png" ) } alt = "Discover apps banner" className = "h-auto w-full object-cover" />
94+ { /* Collapsible hero section */ }
95+ < section
96+ className = "discover-hero"
97+ style = { {
98+ padding : isCollapsed ? "0" : "0 0 2rem 0" ,
99+ maxHeight : lerp ( 520 , 0 , scrollProgress ) ,
100+ overflow : "hidden" ,
101+ transition : "padding 150ms ease-out" ,
102+ } }
103+ >
104+ { /* Banner image - fades out first */ }
105+ < div
106+ className = "discover-entry-image mx-auto mb-8 max-w-discover px-4 lg:px-0"
107+ style = { { opacity : bannerOpacity , maxHeight : lerp ( 320 , 0 , scrollProgress * 1.5 ) , overflow : "hidden" } }
108+ >
109+ < img
110+ src = { assetPath ( "images/discover/top-background.png" ) }
111+ alt = "Discover apps banner"
112+ className = "h-auto w-full object-cover"
113+ style = { { transform : `scale(${ lerp ( 1 , 0.95 , scrollProgress ) } )` , transition : "transform 100ms linear" } }
114+ />
60115 </ div >
116+
117+ { /* Title + subtitle */ }
61118 < div className = "mx-auto max-w-discover px-4 text-center" >
62- < h1 className = "discover-entry-title mx-auto mb-4 font-['Figtree'] text-5xl font-bold leading-[110%] tracking-[-0.01em] sm:mb-8 sm:text-7xl" >
119+ < h1
120+ className = "discover-entry-title mx-auto mb-4 font-['Figtree'] text-5xl font-bold leading-[110%] tracking-[-0.01em]"
121+ style = { {
122+ fontSize : `clamp(${ lerp ( 1.75 , 1.25 , scrollProgress ) } rem, ${ lerp ( 3 , 1.5 , scrollProgress ) } vw, ${ lerp ( 3 , 1.5 , scrollProgress ) } rem)` ,
123+ transform : `scale(${ titleScale } ) translateY(${ titleYOffset } px)` ,
124+ transformOrigin : "center top" ,
125+ opacity : Math . max ( 0.25 , 1 - scrollProgress * 0.75 ) ,
126+ } }
127+ >
63128 Bitcoin Apps Directory
64129 </ h1 >
65- < h2 className = "discover-entry-subtitle mx-auto mb-16 max-w-2xl font-['Figtree'] text-xl font-normal leading-[130%] tracking-[-0.01em] text-gray-600" >
130+ < h2
131+ className = "discover-entry-subtitle mx-auto max-w-2xl font-['Figtree'] text-xl font-normal leading-[130%] tracking-[-0.01em] text-gray-600"
132+ style = { {
133+ opacity : subtitleFade ,
134+ maxHeight : lerp ( 120 , 0 , scrollProgress * 1.5 ) ,
135+ overflow : "hidden" ,
136+ marginBottom : lerp ( 64 , 0 , scrollProgress ) ,
137+ } }
138+ >
66139 A collection of apps, websites and services
67140 < br />
68141 to connect your bitcoin wallet to.
@@ -72,7 +145,24 @@ function App() {
72145
73146 < div className = "pb-0" >
74147 < div className = "mx-auto max-w-discover px-4 lg:px-0" >
75- < SearchBar value = { filters . q } onChange = { ( q ) => setFilters ( ( current ) => ( { ...current , q } ) ) } />
148+ { /* Sticky search bar - becomes sticky when hero is collapsed */ }
149+ < div
150+ className = { isCollapsed ? "discover-search-sticky active" : "discover-search-sticky" }
151+ style = { {
152+ position : isCollapsed ? "sticky" : "relative" ,
153+ top : isCollapsed ? "0" : undefined ,
154+ zIndex : isCollapsed ? 50 : undefined ,
155+ background : isCollapsed ? "linear-gradient(180deg, rgba(255,255,255,0.97) 0%, rgba(255,255,255,0.92) 100%)" : undefined ,
156+ backdropFilter : isCollapsed ? "blur(12px)" : undefined ,
157+ padding : isCollapsed ? "12px 0 16px" : undefined ,
158+ marginBottom : isCollapsed ? "0" : undefined ,
159+ borderBottom : isCollapsed ? "1px solid rgba(0,0,0,0.06)" : undefined ,
160+ boxShadow : isCollapsed ? "0 1px 4px rgba(0,0,0,0.04)" : undefined ,
161+ transition : "padding 150ms ease-out" ,
162+ } }
163+ >
164+ < SearchBar value = { filters . q } onChange = { ( q ) => setFilters ( ( current ) => ( { ...current , q } ) ) } />
165+ </ div >
76166
77167 { ! searching && featured . length > 0 ? (
78168 < section className = "mb-24 p-1" >
0 commit comments