Skip to content

Commit c4e23ec

Browse files
feat(web): cycle hero through photos with crossfade + ambient pan
HeroSlideshow shuffles the manifest on mount and cycles indefinitely (8s visible, 1.5s crossfade). Each layer mounts with a fresh randomized pan vector (±2% translate over a 1.05→1.10 scale ramp); the keyframes live in index.css. Next image is preloaded before the crossfade kicks off, capped at 3s so the cycle never stalls. usePrefersReducedMotion follows the matchMedia pattern from useOnline and disables only the pan animation — crossfades still play. Home swaps the never-loaded <video> for HeroSlideshow + a dark gradient overlay for legibility; hero headline + subhead go to white to read against the photos. Home.test.tsx mock now returns [] for /hero/manifest.json so the slideshow renders nothing in JSDOM. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a8846ac commit c4e23ec

5 files changed

Lines changed: 226 additions & 17 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { useEffect, useState, type CSSProperties } from 'react';
2+
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';
3+
import { cn } from '@/lib/utils';
4+
5+
type Photo = { jpg: string; webp: string };
6+
type PanVector = { fromX: number; fromY: number; toX: number; toY: number };
7+
8+
const VISIBLE_MS = 8000;
9+
const CROSSFADE_MS = 1500;
10+
const LAYER_LIFETIME_MS = CROSSFADE_MS + VISIBLE_MS + CROSSFADE_MS;
11+
const PRELOAD_TIMEOUT_MS = 3000;
12+
const PAN_RANGE_PCT = 2;
13+
14+
function shuffle<T>(arr: readonly T[]): T[] {
15+
const out = arr.slice();
16+
for (let i = out.length - 1; i > 0; i--) {
17+
const j = Math.floor(Math.random() * (i + 1));
18+
const tmp = out[i] as T;
19+
out[i] = out[j] as T;
20+
out[j] = tmp;
21+
}
22+
return out;
23+
}
24+
25+
function randomVector(): PanVector {
26+
const r = () => (Math.random() * 2 - 1) * PAN_RANGE_PCT;
27+
return { fromX: r(), fromY: r(), toX: r(), toY: r() };
28+
}
29+
30+
function preload(url: string): Promise<void> {
31+
return new Promise((resolve) => {
32+
const img = new Image();
33+
let settled = false;
34+
const done = () => {
35+
if (settled) return;
36+
settled = true;
37+
resolve();
38+
};
39+
img.onload = done;
40+
img.onerror = done;
41+
img.src = url;
42+
setTimeout(done, PRELOAD_TIMEOUT_MS);
43+
});
44+
}
45+
46+
interface PanLayerProps {
47+
photo: Photo;
48+
vector: PanVector;
49+
fadingIn: boolean;
50+
fadingOut: boolean;
51+
reducedMotion: boolean;
52+
}
53+
54+
function PanLayer({ photo, vector, fadingIn, fadingOut, reducedMotion }: PanLayerProps) {
55+
const [appeared, setAppeared] = useState(!fadingIn);
56+
57+
useEffect(() => {
58+
if (!fadingIn) return;
59+
const id = requestAnimationFrame(() => {
60+
requestAnimationFrame(() => setAppeared(true));
61+
});
62+
return () => cancelAnimationFrame(id);
63+
}, [fadingIn]);
64+
65+
const opacity = fadingOut ? 0 : appeared ? 1 : 0;
66+
67+
const style = {
68+
opacity,
69+
transition: `opacity ${CROSSFADE_MS}ms ease-in-out`,
70+
animation: reducedMotion ? 'none' : `hero-ken-burns ${LAYER_LIFETIME_MS}ms linear forwards`,
71+
transformOrigin: 'center center',
72+
willChange: 'transform, opacity',
73+
'--kb-from-x': `${vector.fromX}%`,
74+
'--kb-from-y': `${vector.fromY}%`,
75+
'--kb-to-x': `${vector.toX}%`,
76+
'--kb-to-y': `${vector.toY}%`,
77+
} as CSSProperties;
78+
79+
return (
80+
<picture className="absolute inset-0 block" style={style}>
81+
<source srcSet={photo.webp} type="image/webp" />
82+
<img src={photo.jpg} alt="" className="w-full h-full object-cover" loading="eager" />
83+
</picture>
84+
);
85+
}
86+
87+
interface HeroSlideshowProps {
88+
className?: string;
89+
}
90+
91+
export function HeroSlideshow({ className }: HeroSlideshowProps) {
92+
const reducedMotion = usePrefersReducedMotion();
93+
const [photos, setPhotos] = useState<Photo[]>([]);
94+
const [tick, setTick] = useState(0);
95+
const [currentVector, setCurrentVector] = useState<PanVector>(randomVector);
96+
const [nextVector, setNextVector] = useState<PanVector | null>(null);
97+
98+
useEffect(() => {
99+
let cancelled = false;
100+
fetch('/hero/manifest.json')
101+
.then((r) => (r.ok ? (r.json() as Promise<Photo[]>) : []))
102+
.catch(() => [] as Photo[])
103+
.then((data) => {
104+
if (cancelled) return;
105+
if (Array.isArray(data) && data.length > 0) {
106+
setPhotos(shuffle(data));
107+
}
108+
});
109+
return () => {
110+
cancelled = true;
111+
};
112+
}, []);
113+
114+
useEffect(() => {
115+
if (photos.length === 0) return;
116+
let cancelled = false;
117+
const timers: ReturnType<typeof setTimeout>[] = [];
118+
119+
const visibleTimer = setTimeout(() => {
120+
if (cancelled) return;
121+
const nextIdx = (tick + 1) % photos.length;
122+
const nextPhoto = photos[nextIdx];
123+
if (!nextPhoto) return;
124+
preload(nextPhoto.jpg).then(() => {
125+
if (cancelled) return;
126+
const v = randomVector();
127+
setNextVector(v);
128+
const settleTimer = setTimeout(() => {
129+
if (cancelled) return;
130+
setCurrentVector(v);
131+
setNextVector(null);
132+
setTick((prev) => prev + 1);
133+
}, CROSSFADE_MS);
134+
timers.push(settleTimer);
135+
});
136+
}, VISIBLE_MS);
137+
timers.push(visibleTimer);
138+
139+
return () => {
140+
cancelled = true;
141+
timers.forEach(clearTimeout);
142+
};
143+
}, [photos, tick]);
144+
145+
if (photos.length === 0) {
146+
return <div className={cn('absolute inset-0 bg-neutral-900', className)} aria-hidden="true" />;
147+
}
148+
149+
const currentPhoto = photos[tick % photos.length];
150+
const nextPhoto = photos[(tick + 1) % photos.length];
151+
if (!currentPhoto || !nextPhoto) return null;
152+
153+
const transitioning = nextVector !== null;
154+
155+
return (
156+
<div className={cn('relative overflow-hidden', className)} aria-hidden="true">
157+
<PanLayer
158+
key={tick}
159+
photo={currentPhoto}
160+
vector={currentVector}
161+
fadingIn={false}
162+
fadingOut={transitioning}
163+
reducedMotion={reducedMotion}
164+
/>
165+
{nextVector !== null && (
166+
<PanLayer
167+
key={tick + 1}
168+
photo={nextPhoto}
169+
vector={nextVector}
170+
fadingIn
171+
fadingOut={false}
172+
reducedMotion={reducedMotion}
173+
/>
174+
)}
175+
</div>
176+
);
177+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect, useState } from 'react';
2+
3+
const QUERY = '(prefers-reduced-motion: reduce)';
4+
5+
function read(): boolean {
6+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
7+
return window.matchMedia(QUERY).matches;
8+
}
9+
10+
export function usePrefersReducedMotion(): boolean {
11+
const [reduced, setReduced] = useState(read);
12+
13+
useEffect(() => {
14+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
15+
const mql = window.matchMedia(QUERY);
16+
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
17+
mql.addEventListener('change', onChange);
18+
return () => mql.removeEventListener('change', onChange);
19+
}, []);
20+
21+
return reduced;
22+
}

apps/web/src/index.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,13 @@
138138
[id="search-results-dropdown"] {
139139
display: none !important;
140140
}
141+
}
142+
143+
@keyframes hero-ken-burns {
144+
from {
145+
transform: scale(1.05) translate(var(--kb-from-x, 0%), var(--kb-from-y, 0%));
146+
}
147+
to {
148+
transform: scale(1.1) translate(var(--kb-to-x, 0%), var(--kb-to-y, 0%));
149+
}
141150
}

apps/web/src/screens/Home.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
44
import { Button } from '@/components/ui/button';
55
import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
66
import { HelpWantedCard } from '@/components/HelpWantedCard';
7+
import { HeroSlideshow } from '@/components/HeroSlideshow';
78
import { useAuth } from '@/hooks/useAuth';
89
import { api } from '@/lib/api';
910
import { cn } from '@/lib/utils';
@@ -75,28 +76,20 @@ export function Home() {
7576
return (
7677
<div>
7778
{/* Hero */}
78-
<section className="relative bg-gradient-to-br from-primary/5 via-background to-primary/10 border-b border-border">
79-
<div className="absolute inset-0 overflow-hidden pointer-events-none">
80-
<video
81-
autoPlay
82-
muted
83-
loop
84-
playsInline
85-
className="absolute inset-0 w-full h-full object-cover opacity-30"
86-
aria-hidden="true"
87-
>
88-
<source src="/videos/video-small.mp4" type="video/mp4" />
89-
<source src="/videos/video-small.webm" type="video/webm" />
90-
</video>
91-
</div>
79+
<section className="relative border-b border-border bg-neutral-900 overflow-hidden">
80+
<HeroSlideshow className="absolute inset-0" />
81+
<div
82+
className="absolute inset-0 bg-gradient-to-br from-black/55 via-black/30 to-black/55 pointer-events-none"
83+
aria-hidden="true"
84+
/>
9285
<div className="relative container mx-auto px-4 py-20 text-center">
93-
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-4 max-w-3xl mx-auto leading-tight">
86+
<h1 className="text-3xl md:text-5xl font-bold text-white mb-4 max-w-3xl mx-auto leading-tight drop-shadow-md">
9487
Contribute towards technology-related projects that benefit the City of Philadelphia.
9588
</h1>
96-
<p className="text-lg md:text-xl text-muted-foreground mb-8">
89+
<p className="text-lg md:text-xl text-white/85 mb-8 drop-shadow">
9790
No coding experience required.
9891
</p>
99-
<Button asChild size="lg" className="bg-green-600 hover:bg-green-700 text-white">
92+
<Button asChild size="lg" className="bg-green-600 hover:bg-green-700 text-white shadow-lg">
10093
<Link to={person ? '/projects' : '/volunteer'}>
10194
{person ? 'Browse Projects' : 'Volunteer'}
10295
</Link>

apps/web/tests/Home.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ describe('Home', () => {
88
beforeEach(() => {
99
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
1010
if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
11+
if (input.startsWith('/hero/manifest.json')) {
12+
return Promise.resolve(
13+
new Response(JSON.stringify([]), {
14+
status: 200,
15+
headers: { 'content-type': 'application/json' },
16+
}),
17+
);
18+
}
1119
if (input.startsWith('/api/projects')) {
1220
return Promise.resolve(
1321
new Response(JSON.stringify(mockPaginated([], { totalItems: 42 })), {

0 commit comments

Comments
 (0)