Skip to content

Commit 05ddb58

Browse files
MaxGhenisclaude
andcommitted
Smooth scroll-driven header collapse, "by PolicyEngine" branding
- Replace binary scrolled toggle with continuous scroll progress (0→1) - All header properties interpolate smoothly: title size, padding, opacity, background, nav visibility - Change tagline from "a PE project" to "by [PE logo]" - Uses rAF-throttled scroll listener for 60fps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d3ade9 commit 05ddb58

1 file changed

Lines changed: 95 additions & 56 deletions

File tree

app/src/components/Hero.tsx

Lines changed: 95 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @next/next/no-img-element */
22
import Link from "next/link";
3-
import { useEffect, useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
44
import { MODEL_LABELS } from "../modelMeta";
55
import type {
66
BenchData,
@@ -45,6 +45,33 @@ function ViewSelector({
4545

4646
type NavItem = { id: string; label: string };
4747

48+
/** Returns 0 at top, 1 when fully collapsed. Smooth continuous value. */
49+
function getScrollProgress(threshold: number) {
50+
if (typeof window === "undefined") return 0;
51+
return Math.min(1, Math.max(0, window.scrollY / threshold));
52+
}
53+
54+
function useScrollProgress(threshold = 80) {
55+
const [progress, setProgress] = useState(() => getScrollProgress(threshold));
56+
const rafRef = useRef(0);
57+
58+
useEffect(() => {
59+
const onScroll = () => {
60+
cancelAnimationFrame(rafRef.current);
61+
rafRef.current = requestAnimationFrame(() => {
62+
setProgress(getScrollProgress(threshold));
63+
});
64+
};
65+
window.addEventListener("scroll", onScroll, { passive: true });
66+
return () => {
67+
window.removeEventListener("scroll", onScroll);
68+
cancelAnimationFrame(rafRef.current);
69+
};
70+
}, [threshold]);
71+
72+
return progress;
73+
}
74+
4875
export default function Hero({
4976
selectedView,
5077
onSelectView,
@@ -60,14 +87,8 @@ export default function Hero({
6087
navItems: readonly NavItem[];
6188
activeNav: string;
6289
}) {
63-
const [scrolled, setScrolled] = useState(false);
64-
65-
useEffect(() => {
66-
const onScroll = () => setScrolled(window.scrollY > 60);
67-
onScroll();
68-
window.addEventListener("scroll", onScroll, { passive: true });
69-
return () => window.removeEventListener("scroll", onScroll);
70-
}, []);
90+
const progress = useScrollProgress(80);
91+
const scrolled = progress > 0.5;
7192

7293
const isGlobal = selectedView === "global";
7394
const benchData = isGlobal ? null : (data as BenchData);
@@ -96,72 +117,84 @@ export default function Hero({
96117
{ value: `${benchData!.programStats.length}`, label: "Outputs" },
97118
];
98119

120+
// Continuous interpolation helpers
121+
const lerp = (a: number, b: number) => a + (b - a) * progress;
122+
const expandedPadTop = lerp(40, 8); // pt-10 → py-2
123+
const expandedPadBot = lerp(16, 8);
124+
const titleSize = lerp(36, 16); // text-4xl → text-base
125+
const taglineOpacity = 1 - progress;
126+
const expandOpacity = 1 - Math.min(1, progress * 2); // fade out faster
127+
const expandHeight = `${(1 - progress) * 140}px`;
128+
const navOpacity = Math.max(0, (progress - 0.3) / 0.7); // fade in after 30%
129+
const bgOpacity = progress;
130+
99131
return (
100-
<header
101-
className={`sticky top-0 z-40 transition-[background-color,border-color] duration-300 ${
102-
scrolled
103-
? "bg-bg/90 backdrop-blur-md border-b border-border"
104-
: "bg-transparent border-b border-transparent"
105-
}`}
106-
>
107-
{/* Gradient glow — fades out when scrolled */}
132+
<header className="sticky top-0 z-40">
133+
{/* Background — fades in */}
134+
<div
135+
className="absolute inset-0 border-b backdrop-blur-md"
136+
style={{
137+
opacity: bgOpacity,
138+
backgroundColor: `color-mix(in srgb, var(--color-bg) ${Math.round(bgOpacity * 90)}%, transparent)`,
139+
borderColor: `color-mix(in srgb, var(--color-border) ${Math.round(bgOpacity * 100)}%, transparent)`,
140+
}}
141+
/>
142+
143+
{/* Gradient glow — fades out */}
108144
<div
109-
className={`absolute inset-x-0 top-0 h-[280px] bg-[radial-gradient(circle_at_top,_color-mix(in_srgb,var(--color-primary)_13%,transparent),transparent_58%)] pointer-events-none transition-opacity duration-300 ${
110-
scrolled ? "opacity-0" : "opacity-100"
111-
}`}
145+
className="absolute inset-x-0 top-0 h-[280px] bg-[radial-gradient(circle_at_top,_color-mix(in_srgb,var(--color-primary)_13%,transparent),transparent_58%)] pointer-events-none"
146+
style={{ opacity: 1 - progress }}
112147
/>
113148

114149
<div className="relative max-w-7xl mx-auto px-4 sm:px-6">
115-
{/* Top row: brand + view selector — always visible */}
150+
{/* Top row: brand + nav + view selector */}
116151
<div
117-
className={`flex items-center gap-3 transition-[padding] duration-300 ${
118-
scrolled ? "py-2" : "pt-8 pb-4 sm:pt-10 sm:pb-4"
119-
}`}
152+
className="flex items-center gap-3"
153+
style={{
154+
paddingTop: `${expandedPadTop}px`,
155+
paddingBottom: `${expandedPadBot}px`,
156+
}}
120157
>
121158
<Link
122159
href="/"
123-
className="shrink-0 flex items-center gap-2.5 transition-colors hover:opacity-80"
160+
className="shrink-0 flex items-center gap-2 hover:opacity-80"
124161
>
125162
<span
126-
className={`font-[family-name:var(--font-display)] tracking-tight text-text transition-[font-size] duration-300 ${
127-
scrolled ? "text-base" : "text-3xl sm:text-4xl"
128-
}`}
163+
className="font-[family-name:var(--font-display)] tracking-tight text-text leading-none"
164+
style={{ fontSize: `${titleSize}px` }}
129165
>
130166
PolicyBench
131167
</span>
132-
{/* "a PolicyEngine project" tagline — visible when expanded */}
168+
{/* "by [PE logo]" tagline */}
133169
<span
134-
className={`flex items-center gap-1.5 transition-all duration-300 overflow-hidden ${
135-
scrolled
136-
? "opacity-0 max-w-0"
137-
: "opacity-60 max-w-[200px]"
138-
}`}
170+
className="flex items-center gap-1.5 overflow-hidden"
171+
style={{ opacity: taglineOpacity * 0.6, maxWidth: taglineOpacity > 0.05 ? "160px" : "0px" }}
139172
>
140-
<span className="text-text-muted text-sm whitespace-nowrap">a</span>
173+
<span className="text-text-muted text-sm whitespace-nowrap">by</span>
141174
<img
142175
src="/assets/policyengine-logo.svg"
143176
alt="PolicyEngine"
144177
className="h-3.5 w-auto shrink-0"
145178
/>
146-
<span className="text-text-muted text-sm whitespace-nowrap">project</span>
147179
</span>
148180
</Link>
149181

150-
{/* Nav tabs — slide in when scrolled */}
182+
{/* Nav tabs — fade in as you scroll */}
151183
<div
152-
className={`flex items-center gap-0 transition-all duration-300 overflow-hidden ${
153-
scrolled
154-
? "opacity-100 max-w-[600px] ml-1"
155-
: "opacity-0 max-w-0 ml-0"
156-
}`}
184+
className="flex items-center overflow-hidden"
185+
style={{
186+
opacity: navOpacity,
187+
maxWidth: navOpacity > 0.05 ? "600px" : "0px",
188+
marginLeft: navOpacity > 0.05 ? "4px" : "0px",
189+
}}
157190
>
158191
<div className="h-4 w-px bg-border shrink-0 mx-2" />
159192
<div className="flex min-w-max gap-0.5">
160193
{navItems.map((item) => (
161194
<a
162195
key={item.id}
163196
href={`#${item.id}`}
164-
className={`px-2.5 py-2 text-[11px] font-medium tracking-wider uppercase transition-colors border-b-2 sm:px-3 ${
197+
className={`px-2.5 py-2 text-[11px] font-medium tracking-wider uppercase border-b-2 sm:px-3 ${
165198
activeNav === item.id
166199
? "border-primary text-primary"
167200
: "border-transparent text-text-secondary hover:text-text"
@@ -181,26 +214,31 @@ export default function Hero({
181214
compact={scrolled}
182215
/>
183216

184-
{/* Paper link — only when scrolled */}
217+
{/* Paper link — fades in with nav */}
185218
<div
186-
className={`transition-all duration-300 overflow-hidden ${
187-
scrolled ? "opacity-100 max-w-[80px]" : "opacity-0 max-w-0"
188-
}`}
219+
className="overflow-hidden"
220+
style={{
221+
opacity: navOpacity,
222+
maxWidth: navOpacity > 0.05 ? "80px" : "0px",
223+
}}
189224
>
190225
<Link
191226
href="/paper"
192-
className="rounded-full border border-border bg-card px-3 py-1 text-[11px] font-medium uppercase tracking-wider text-text-secondary transition-colors hover:border-primary/40 hover:text-primary whitespace-nowrap"
227+
className="rounded-full border border-border bg-card px-3 py-1 text-[11px] font-medium uppercase tracking-wider text-text-secondary hover:border-primary/40 hover:text-primary whitespace-nowrap"
193228
>
194229
Paper
195230
</Link>
196231
</div>
197232
</div>
198233

199-
{/* Expanded content: subtitle + stats — collapses on scroll */}
234+
{/* Expanded content: subtitle + stats */}
200235
<div
201-
className={`overflow-hidden transition-all duration-300 ease-out ${
202-
scrolled ? "max-h-0 opacity-0 pb-0" : "max-h-40 opacity-100 pb-6 sm:pb-8"
203-
}`}
236+
className="overflow-hidden"
237+
style={{
238+
maxHeight: expandHeight,
239+
opacity: expandOpacity,
240+
paddingBottom: expandOpacity > 0.05 ? `${lerp(32, 0)}px` : "0px",
241+
}}
204242
>
205243
<p className="text-text-secondary text-sm sm:text-base max-w-xl leading-relaxed">
206244
{subtitle}{" "}
@@ -240,10 +278,11 @@ export default function Hero({
240278
</div>
241279
</div>
242280

243-
{/* Bottom border gradient — only when not scrolled */}
244-
{!scrolled && (
245-
<div className="h-px bg-gradient-to-r from-transparent via-primary/25 to-transparent" />
246-
)}
281+
{/* Bottom border gradient — fades out */}
282+
<div
283+
className="h-px bg-gradient-to-r from-transparent via-primary/25 to-transparent"
284+
style={{ opacity: 1 - progress }}
285+
/>
247286
</header>
248287
);
249288
}

0 commit comments

Comments
 (0)