Skip to content

Commit 84b8110

Browse files
committed
feat: collapse hero header on scroll
- Add scroll-based collapsible hero with smooth animations - Banner image fades out first, then title scales down - Subtitle fades and collapses - Search bar becomes sticky with frosted glass effect when hero is collapsed - Uses requestAnimationFrame for smooth, jank-free scroll tracking - All transitions use progressive interpolation (no hard breakpoints)
1 parent e92ddc2 commit 84b8110

1 file changed

Lines changed: 97 additions & 7 deletions

File tree

src/App.tsx

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { APPS } from "./data/apps";
33
import {
44
CATEGORY_LABELS,
@@ -27,8 +27,38 @@ import { FooterCta } from "./components/footer-cta";
2727
import { Card } from "./components/ui/card";
2828
import { 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+
3059
function 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

Comments
 (0)