From 84b8110a8338c19e960a5c7e51474270ac7b3c23 Mon Sep 17 00:00:00 2001 From: Hermes Alby Date: Wed, 8 Apr 2026 08:45:33 +0000 Subject: [PATCH 1/2] 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) --- src/App.tsx | 104 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5c16f8e..3ac90c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { APPS } from "./data/apps"; import { CATEGORY_LABELS, @@ -27,8 +27,38 @@ import { FooterCta } from "./components/footer-cta"; import { Card } from "./components/ui/card"; import { assetPath } from "./lib/assets"; +// Track scroll progress from 0 to 1 over a given pixel distance +// Uses requestAnimationFrame to avoid jank +function useScrollProgress(thresholdPx = 200) { + const [progress, setProgress] = useState(0); + const tickingRef = useRef(false); + + const handler = useCallback(() => { + if (tickingRef.current) return; + tickingRef.current = true; + requestAnimationFrame(() => { + const scrollY = window.scrollY || document.documentElement.scrollTop; + setProgress(Math.min(1, Math.max(0, scrollY / thresholdPx))); + tickingRef.current = false; + }); + }, [thresholdPx]); + + useEffect(() => { + window.addEventListener("scroll", handler, { passive: true }); + return () => window.removeEventListener("scroll", handler); + }, [handler]); + + return progress; +} + +/** Interpolate between two values based on progress (0..1) */ +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + function App() { const [filters, setFilters] = useState(() => parseFiltersFromSearch(window.location.search)); + const scrollProgress = useScrollProgress(); useEffect(() => { document.title = "Bitcoin Apps Directory"; @@ -52,17 +82,60 @@ function App() { const searching = filters.q.trim().length > 0; + // --- Collapsible header state --- + const isCollapsed = scrollProgress >= 0.95; + const bannerOpacity = Math.max(0, 1 - scrollProgress * 2.5); + const titleScale = lerp(1, 0.65, scrollProgress); + const subtitleFade = Math.max(0, 1 - scrollProgress * 2); + const titleYOffset = lerp(0, 8, scrollProgress); + return (
-
-
- Discover apps banner + {/* Collapsible hero section */} +
+ {/* Banner image - fades out first */} +
+ Discover apps banner
+ + {/* Title + subtitle */}
-

+

Bitcoin Apps Directory

-

+

A collection of apps, websites and services
to connect your bitcoin wallet to. @@ -72,7 +145,24 @@ function App() {
- setFilters((current) => ({ ...current, q }))} /> + {/* Sticky search bar - becomes sticky when hero is collapsed */} +
+ setFilters((current) => ({ ...current, q }))} /> +
{!searching && featured.length > 0 ? (
From 2b8ab1928f3290e35471a156768187aee3d90ee0 Mon Sep 17 00:00:00 2001 From: Hermes Alby Date: Fri, 10 Apr 2026 11:39:03 +0000 Subject: [PATCH 2/2] fix: address header scroll UX issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Top image no longer cut — removed maxHeight constraints on hero section and banner div that were clipping the image 2. Category pills no longer covered — removed sticky search bar that overlapped them (pills remain sticky as before) 3. Removed shadow on sticky search element (element removed entirely) --- src/App.tsx | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3ac90c9..76bb019 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,7 +96,6 @@ function App() { className="discover-hero" style={{ padding: isCollapsed ? "0" : "0 0 2rem 0", - maxHeight: lerp(520, 0, scrollProgress), overflow: "hidden", transition: "padding 150ms ease-out", }} @@ -104,7 +103,7 @@ function App() { {/* Banner image - fades out first */}
- {/* Sticky search bar - becomes sticky when hero is collapsed */} -
- setFilters((current) => ({ ...current, q }))} /> -
+ setFilters((current) => ({ ...current, q }))} /> {!searching && featured.length > 0 ? (