Skip to content

Commit fc3bed9

Browse files
authored
feat: refactor Hero component to use WebGL background (#2246)
Assisted-by: @claude
1 parent dc8fbc0 commit fc3bed9

11 files changed

Lines changed: 353 additions & 250 deletions

File tree

Lines changed: 54 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
22
import './Hero.css';
3-
import { Image } from 'astro:assets';
43
import { Code } from 'astro-expressive-code/components';
54
import { Icon } from 'astro-icon/components';
65
import { H1, Button, Container, Flex, BodyMd, Grid, Col } from '@/components/primitives';
76
import { getEntry } from 'astro:content';
87
import { getLangFromUrl, useTranslations } from '@/i18n/utils';
8+
import { Image } from 'astro:assets';
99
1010
const lang = getLangFromUrl(Astro.url);
1111
const t = useTranslations(lang);
@@ -24,73 +24,12 @@ app.get('/', (req, res) => {
2424
app.listen(port, () => {
2525
console.log(\`Example app listening on port \${port}\`)
2626
})`;
27-
28-
interface Props {
29-
videoSrc: string;
30-
videoSrcLight: string;
31-
posterSrc: string;
32-
posterSrcLight: string;
33-
}
34-
35-
const { videoSrc, videoSrcLight, posterSrc, posterSrcLight } = Astro.props;
3627
---
3728

3829
<section class="hero">
39-
<div class="hero__video-container">
40-
<Image
41-
class="hero__video-poster hero__video-poster--dark"
42-
src={posterSrc}
43-
width={1920}
44-
height={1080}
45-
loading="eager"
46-
priority={true}
47-
alt=""
48-
aria-hidden="true"
49-
/>
50-
51-
<Image
52-
class="hero__video-poster hero__video-poster--light"
53-
src={posterSrcLight}
54-
width={1920}
55-
height={1080}
56-
loading="eager"
57-
priority={true}
58-
alt=""
59-
aria-hidden="true"
60-
/>
61-
62-
<video
63-
class="hero__video hero__video--dark"
64-
autoplay
65-
muted
66-
loop
67-
playsinline
68-
preload="metadata"
69-
data-src={videoSrc}></video>
70-
71-
<video
72-
class="hero__video hero__video--light"
73-
autoplay
74-
muted
75-
loop
76-
playsinline
77-
preload="metadata"
78-
data-src={videoSrcLight}></video>
79-
30+
<div class="hero__bg-container">
31+
<canvas class="hero__canvas" aria-hidden="true"></canvas>
8032
<div class="hero__video-overlay"></div>
81-
82-
<button
83-
class="hero__video-control"
84-
type="button"
85-
title={t('hero.videoPause')}
86-
aria-label={t('hero.videoPause')}
87-
data-label-pause={t('hero.videoPause')}
88-
data-label-play={t('hero.videoPlay')}
89-
data-state="paused"
90-
>
91-
<Icon name="fluent:pause-16-filled" class="hero__video-control-pause" />
92-
<Icon name="fluent:play-16-filled" class="hero__video-control-play" />
93-
</button>
9433
</div>
9534
<Container>
9635
<div class="hero__content">
@@ -102,6 +41,7 @@ const { videoSrc, videoSrcLight, posterSrc, posterSrcLight } = Astro.props;
10241
class="hero__logo-kawaii"
10342
width={400}
10443
height={225}
44+
loading="lazy"
10545
/>
10646
<Icon name="logo-express-black" class="hero__logo-icon--dark hero__logo-default" />
10747
<Icon name="logo-express-white" class="hero__logo-icon--light hero__logo-default" />
@@ -133,138 +73,62 @@ const { videoSrc, videoSrcLight, posterSrc, posterSrcLight } = Astro.props;
13373
</section>
13474

13575
<script>
136-
function initHeroVideo() {
137-
const container = document.querySelector<HTMLDivElement>('.hero__video-container');
138-
if (!container) return;
139-
140-
const controlButton = container.querySelector<HTMLButtonElement>('.hero__video-control');
141-
if (!controlButton) return;
142-
143-
const darkVideo = container.querySelector<HTMLVideoElement>('.hero__video--dark');
144-
const lightVideo = container.querySelector<HTMLVideoElement>('.hero__video--light');
145-
const darkPoster = container.querySelector<HTMLImageElement>('.hero__video-poster--dark');
146-
const lightPoster = container.querySelector<HTMLImageElement>('.hero__video-poster--light');
147-
148-
let currentVideo: HTMLVideoElement | null = null;
149-
let currentPoster: HTMLImageElement | null = null;
150-
151-
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
152-
153-
function getIsDark() {
154-
const theme = document.documentElement.getAttribute('data-theme');
155-
return (
156-
theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
157-
);
158-
}
159-
160-
function getAssets() {
161-
const isDark = getIsDark();
162-
return {
163-
video: isDark ? darkVideo : lightVideo,
164-
poster: isDark ? darkPoster : lightPoster,
165-
};
166-
}
167-
168-
function loadVideo(video: HTMLVideoElement) {
169-
const src = video.getAttribute('data-src');
170-
if (!src) return;
171-
172-
// do not load video if src is loaded (reduce network call)
173-
if (!video.src) {
174-
video.src = src;
175-
video.load();
176-
}
177-
}
178-
179-
function activateVideo(video: HTMLVideoElement | null, poster: HTMLImageElement | null) {
180-
if (!video || !poster) return;
181-
182-
if (currentPoster) currentPoster.classList.remove('hide_poster');
183-
184-
currentPoster = poster;
185-
186-
if (currentVideo) {
187-
currentVideo.pause();
188-
currentVideo.classList.remove('hero__video--active');
189-
}
190-
191-
currentVideo = video;
192-
193-
if (prefersReducedMotion) {
194-
currentVideo.pause();
195-
currentVideo.removeAttribute('autoplay');
196-
currentVideo.classList.remove('hero__video--active');
197-
if (controlButton) controlButton.style.display = 'none';
76+
// Import immediately — module is small (~6KB) and shader compilation is async.
77+
// initHeroWebGL renders the first frame during init (in resize()),
78+
// so the arc appears as soon as shaders compile — no image fallback needed.
79+
import('./hero-background').then(({ initHeroWebGL }) => {
80+
const canvas = document.querySelector<HTMLCanvasElement>('.hero__canvas');
81+
if (!canvas) return;
82+
83+
const isMobile = window.matchMedia('(max-width: 768px)');
84+
const staticOnly =
85+
isMobile.matches || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
86+
87+
initHeroWebGL(canvas, staticOnly).then((bg) => {
88+
if (!bg) return;
89+
90+
// On mobile / reduced-motion: render one frame, done
91+
if (staticOnly) {
92+
bg.play();
19893
return;
19994
}
20095

201-
loadVideo(video);
202-
203-
// check if video is ready to play then just update background animation
204-
// returning early to stop repeated listener on theme change
205-
// ref: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState#htmlmediaelement.have_future_data
206-
if (video.readyState >= video.HAVE_FUTURE_DATA) {
207-
updateStatus(video, poster);
208-
return;
96+
// Desktop: defer animation loop to after page load + idle
97+
function startAnimation() {
98+
const idle = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
99+
idle(() => {
100+
bg.play();
101+
102+
const observer = new IntersectionObserver(
103+
(entries) => {
104+
entries.forEach((entry) => {
105+
if (entry.isIntersecting) {
106+
bg.play();
107+
} else {
108+
bg.pause();
109+
}
110+
});
111+
},
112+
{ rootMargin: '80px' }
113+
);
114+
115+
observer.observe(canvas);
116+
117+
document.addEventListener('visibilitychange', () => {
118+
if (document.hidden) {
119+
bg.pause();
120+
} else {
121+
bg.play();
122+
}
123+
});
124+
});
209125
}
210126

211-
video.addEventListener('canplay', () => updateStatus(video, poster), { once: true });
212-
}
213-
214-
function updateVideo() {
215-
const { video: nextVideo, poster: hidePoster } = getAssets();
216-
217-
activateVideo(nextVideo, hidePoster);
218-
}
219-
220-
function updateStatus(video: HTMLVideoElement | null, poster: HTMLImageElement | null) {
221-
if (!video || !poster) return;
222-
// if video is playable then hide poster
223-
poster.classList.add('hide_poster');
224-
// activate correct video animations
225-
video.classList.add('hero__video--active');
226-
video.play().catch(() => {});
227-
// update controll btn on video load
228-
controlButton?.setAttribute('data-state', 'playing');
229-
}
230-
231-
// Control button
232-
controlButton.addEventListener('click', () => {
233-
if (!currentVideo) return;
234-
235-
if (currentVideo.paused) {
236-
currentVideo.play().catch(() => {});
237-
controlButton.setAttribute('data-state', 'playing');
127+
if (document.readyState === 'complete') {
128+
startAnimation();
238129
} else {
239-
currentVideo.pause();
240-
controlButton.setAttribute('data-state', 'paused');
130+
window.addEventListener('load', startAnimation, { once: true });
241131
}
242132
});
243-
244-
// stop video on hidden tabs
245-
document.addEventListener('visibilitychange', () => {
246-
if (!currentVideo) return;
247-
248-
if (document.hidden) {
249-
currentVideo.pause();
250-
} else if (!prefersReducedMotion) {
251-
currentVideo.play().catch(() => {});
252-
}
253-
});
254-
255-
// Initial run
256-
updateVideo();
257-
258-
// React to theme changes
259-
const observer = new MutationObserver(() => {
260-
updateVideo();
261-
});
262-
263-
observer.observe(document.documentElement, {
264-
attributes: true,
265-
attributeFilter: ['data-theme'],
266-
});
267-
}
268-
269-
initHeroVideo();
133+
});
270134
</script>

src/components/patterns/Hero/Hero.css

Lines changed: 12 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,60 +14,25 @@
1414
}
1515
}
1616

17-
/* ================= VIDEO CONTAINER ================= */
18-
19-
.hero__video-container {
17+
.hero__bg-container {
2018
position: absolute;
21-
inset: 0;
19+
top: 0;
20+
left: 50%;
21+
transform: translateX(-50%);
22+
width: 100vw;
23+
height: 100%;
2224
z-index: 0;
2325
overflow: hidden;
2426
}
2527

26-
.hero__video,
27-
.hero__video-poster {
28+
.hero__canvas {
2829
position: absolute;
29-
top: 50%;
30-
left: 50%;
31-
transform: translate(-50%, -50%);
32-
min-width: 100%;
33-
min-height: 100%;
34-
object-fit: cover;
35-
}
36-
37-
/* ================= VIDEO ================= */
38-
39-
.hero__video {
40-
opacity: 0;
41-
pointer-events: none;
42-
}
43-
44-
/* visible video */
45-
.hero__video--active {
46-
opacity: 1;
47-
}
48-
49-
/* ================= POSTERS ================= */
50-
51-
.hero__video-poster {
52-
opacity: 0;
53-
transition: opacity 0.3s ease;
54-
}
55-
56-
[data-theme='dark'] .hero__video-poster--dark {
57-
opacity: 1;
58-
}
59-
60-
[data-theme='light'] .hero__video-poster--light {
61-
opacity: 1;
62-
}
63-
64-
/* hide poster if video is playable */
65-
.hero__video-poster.hide_poster {
66-
opacity: 0;
30+
top: 0;
31+
left: 0;
32+
width: 100%;
33+
height: 100%;
6734
}
6835

69-
/* ================= OVERLAY ================= */
70-
7136
.hero__video-overlay {
7237
position: absolute;
7338
inset: 0;
@@ -80,9 +45,9 @@
8045
rgba(255, 255, 255, 0.3) 70%,
8146
var(--color-bg-primary) 100%
8247
);
48+
z-index: 1;
8349
}
8450

85-
/* Dark theme overlay */
8651
[data-theme='dark'] .hero__video-overlay {
8752
background: linear-gradient(
8853
180deg,

0 commit comments

Comments
 (0)