Skip to content

Commit 08d1a68

Browse files
committed
@
Smooth scroll polish: Lenis + banner ease curve - Lenis drives global window scroll with lerp 0.1. Instance lives on window and persists across SPA nav — the ClientRouter swaps DOM but the scroll container stays, so no teardown/recreate. - Banner pre-pin travel bent from linear (sticky default) to smoothstep so it visibly slows on lift-off and on settle into pin. pinDist cached in a ResizeObserver so the scroll handler is reflow-free. - CineBanner explicit a.play() in init: the autoplay HTML attr fires reliably on first load but not on SPA-rendered <video>; ClientRouter swaps after the parser's autoplay heuristic has run, leaving the new video paused on a blank frame. @
1 parent 5697cc4 commit 08d1a68

5 files changed

Lines changed: 151 additions & 0 deletions

File tree

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@astrojs/mdx": "^5.0.3",
1616
"astro": "^6.1.8",
1717
"d3-delaunay": "^6.0.4",
18+
"lenis": "^1.3.23",
1819
"verovio": "^6.1.0"
1920
}
2021
}

src/components/CineBanner.astro

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ const single = sources.length === 1;
9191
let active = a;
9292
let standby = b;
9393

94+
// The `autoplay` HTML attribute fires reliably on first load
95+
// but not on SPA-rendered video — Astro's ClientRouter swaps
96+
// DOM after the parser's autoplay heuristic has run, leaving
97+
// the new <video> paused on a blank frame. Kick it off
98+
// explicitly. Catch is for autoplay-policy rejections (rare
99+
// here since the video is muted, but defensive).
100+
a.play().catch(() => {});
101+
94102
function swap() {
95103
standby.currentTime = 0;
96104
standby.play();

src/layouts/BaseLayout.astro

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import '../styles/tokens.css';
3+
import 'lenis/dist/lenis.css';
34
import { ClientRouter } from 'astro:transitions';
45
56
interface Props {
@@ -83,6 +84,25 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
8384
</div>
8485

8586
<script>
87+
// --- Global smooth scroll via Lenis ---
88+
// One persistent instance lives on window. Lenis animates window
89+
// scrollY via rAF — sticky/fixed positioning still works natively
90+
// because the actual scroll position updates, just smoothly. The
91+
// ClientRouter swaps DOM but doesn't replace the scroll container,
92+
// so we don't tear down/recreate on navigation; the rAF loop just
93+
// keeps running.
94+
import Lenis from 'lenis';
95+
const lw = window as unknown as { __lenisInstalled?: boolean };
96+
if (!lw.__lenisInstalled) {
97+
lw.__lenisInstalled = true;
98+
const lenis = new Lenis({ lerp: 0.1 });
99+
const raf = (time: number) => {
100+
lenis.raf(time);
101+
requestAnimationFrame(raf);
102+
};
103+
requestAnimationFrame(raf);
104+
}
105+
86106
// --- Space key → toggle stage pause ---
87107
// Canvas components listen for `stage:pause` and freeze their rAF loop.
88108
//

src/pages/index.astro

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,101 @@ const BANNER_H = 120;
113113
}, { once: true });
114114
}
115115

116+
// Bend the banner's pre-pin travel from linear (sticky's default) to
117+
// ease-in-out, so it visibly slows as it lifts off natural and again
118+
// as it settles into pin. Lenis already smooths the scroll itself;
119+
// this curve is applied on top so the *banner's* progress through its
120+
// travel is non-linear regardless of input speed.
121+
//
122+
// Math: at scrollY S in [0, pinDist], smoothstep gives the eased
123+
// progress p; the banner's eased viewport y is naturalY - p*pinDist,
124+
// and sticky's actual is naturalY - S, so the delta we apply via
125+
// translate is pinDist * (t - p) where t = S/pinDist. Outside the
126+
// pre-pin range the delta is 0 (sticky alone is correct).
127+
let bannerEaseCleanup: (() => void) | null = null;
128+
129+
function bannerEase() {
130+
bannerEaseCleanup?.();
131+
bannerEaseCleanup = null;
132+
133+
const banner = document.querySelector<HTMLElement>('.banner-wrap');
134+
if (!banner) return;
135+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
136+
137+
const init = () => {
138+
// Same .enter-class trick as before: animation-fill-mode: both
139+
// would lock translateY(0) over our inline transform forever.
140+
banner.classList.remove('enter');
141+
142+
let pinDist = 0;
143+
let raf = 0;
144+
let stopped = false;
145+
146+
const measure = () => {
147+
const pinY = parseFloat(getComputedStyle(banner).top) || 0;
148+
let natY = 0;
149+
let el: HTMLElement | null = banner;
150+
while (el) {
151+
natY += el.offsetTop;
152+
el = el.offsetParent as HTMLElement | null;
153+
}
154+
pinDist = Math.max(0, natY - pinY);
155+
};
156+
157+
const apply = () => {
158+
if (stopped) return;
159+
const s = window.scrollY;
160+
if (pinDist <= 0 || s <= 0 || s >= pinDist) {
161+
banner.style.transform = '';
162+
} else {
163+
const t = s / pinDist;
164+
const p = t * t * (3 - 2 * t);
165+
banner.style.transform = `translate3d(0, ${pinDist * (t - p)}px, 0)`;
166+
}
167+
raf = 0;
168+
};
169+
170+
const onScroll = () => {
171+
if (!raf && !stopped) raf = requestAnimationFrame(apply);
172+
};
173+
174+
measure();
175+
apply();
176+
window.addEventListener('scroll', onScroll, { passive: true });
177+
178+
// Layout shifts (font swap, header height change, breakpoint) move
179+
// the natural and pin positions; recompute and re-apply.
180+
const ro = new ResizeObserver(() => {
181+
measure();
182+
apply();
183+
});
184+
ro.observe(banner);
185+
ro.observe(document.documentElement);
186+
187+
bannerEaseCleanup = () => {
188+
stopped = true;
189+
if (raf) cancelAnimationFrame(raf);
190+
window.removeEventListener('scroll', onScroll);
191+
ro.disconnect();
192+
banner.style.transform = '';
193+
};
194+
};
195+
196+
const anim = banner.getAnimations?.()[0];
197+
if (anim && anim.playState !== 'finished') {
198+
anim.finished.then(init).catch(() => {});
199+
} else {
200+
init();
201+
}
202+
}
203+
116204
attachHeat();
117205
trackHeaderHeight();
206+
bannerEase();
118207
document.addEventListener('astro:page-load', () => {
119208
attachHeat();
120209
trackHeaderHeight();
210+
bannerEase();
121211
});
122212
</script>
123213

0 commit comments

Comments
 (0)