Add the stacked project deck component

This commit is contained in:
David 2026-11-03 14:10:11 +02:00
parent 2c7a16dccb
commit 6373339c3f
3 changed files with 135 additions and 9 deletions

View file

@ -0,0 +1,125 @@
import { useRef, useState, type ReactNode } from 'react'
import { img, projects, type Project } from '@/data/content'
import { useReducedMotion } from '@/hooks/useReducedMotion'
import { useStackScrub } from '@/hooks/useStackScrub'
import { LoopVideo } from './LoopVideo'
import { SectionLabel } from './Reveal'
const INTRO =
'Retail parks, schools, precincts and lifestyle estates across Gauteng and Limpopo, delivered with full civil services from bulk earthworks to the final surface.'
function StackHeader() {
return (
<header className="mx-auto w-full max-w-[1440px] text-center">
<div className="flex justify-center">
<SectionLabel index="03" dark={false}>
Project log
</SectionLabel>
</div>
<h2 className="display mt-6 text-[clamp(1.9rem,4vw,3.2rem)] font-normal leading-[1.02] text-ink">
Five sites. <span className="text-ink/40">One standard.</span>
</h2>
<p className="mx-auto mt-5 max-w-[52ch] text-sm leading-relaxed text-ink/60">{INTRO}</p>
</header>
)
}
function CardFace({ p, media }: { p: Project; media: ReactNode }) {
return (
<div className="relative h-full w-full overflow-hidden rounded-sm border border-bone/10 bg-coal shadow-[0_30px_80px_-40px_rgba(0,0,0,0.95)]">
{media}
{/* Readability: heavy at the bottom for the copy, light at the top for the photo. */}
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/55 to-ink/20" />
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-ink/70 to-transparent" />
{/* Gold glow, the Mega equivalent of the reference's ember wash. */}
<span
className="pointer-events-none absolute -right-16 -top-16 h-56 w-56 rounded-full bg-gold/20 blur-3xl"
aria-hidden="true"
/>
<div className="relative flex h-full flex-col justify-between p-6 sm:p-8">
<div className="flex items-center justify-between gap-4">
<span className="font-mono text-[0.68rem] uppercase tracking-[0.2em] text-gold">{p.ref}</span>
<span className="truncate font-mono text-[0.68rem] uppercase tracking-[0.14em] text-bone/60">
{p.location}
</span>
</div>
<div>
<h3 className="display text-[clamp(1.5rem,3vw,2.5rem)] font-normal leading-[1.05] text-bone">
{p.name}
</h3>
<ul className="mt-4 flex flex-wrap gap-1.5">
{p.scope.map((t) => (
<li key={t} className="rounded-full border border-bone/20 px-2.5 py-1 text-[0.68rem] text-bone/75">
{t}
</li>
))}
</ul>
<p className="mt-4 max-w-[46ch] text-sm leading-relaxed text-bone/75">{p.summary}</p>
</div>
</div>
</div>
)
}
export function ProjectStack() {
const reduced = useReducedMotion()
const trackRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<(HTMLElement | null)[]>([])
const [active, setActive] = useState(-1)
useStackScrub(trackRef, cardRefs, !reduced, setActive)
const media = (p: Project, i: number, isActive: boolean) =>
p.video ? (
<LoopVideo
src={p.video}
poster={img(p.images[0], i > 0)}
active={isActive}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<img
src={img(p.images[0], i > 0)}
alt={`${p.name}, ${p.location}`}
loading={i === 0 ? 'eager' : 'lazy'}
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
)
if (reduced) {
return (
<div className="mt-16 sm:mt-24">
<StackHeader />
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((p, i) => (
<article key={p.ref} className="aspect-[4/3]">
<CardFace p={p} media={media(p, i, true)} />
</article>
))}
</div>
</div>
)
}
return (
<div ref={trackRef} className="relative h-[400vh]">
<div className="sticky top-0 flex h-[100svh] flex-col items-center justify-center gap-6 overflow-hidden px-5 sm:gap-8 sm:px-8 lg:px-14">
<StackHeader />
<div className="relative h-[min(26rem,46vh)] w-[min(90vw,24rem)] sm:h-[min(30rem,54vh)] sm:w-[min(92vw,44rem)]">
{projects.map((p, i) => (
<article
key={p.ref}
ref={(el) => {
cardRefs.current[i] = el
}}
className="absolute inset-0 opacity-0 will-change-[transform,opacity]"
>
<CardFace p={p} media={media(p, i, active === i)} />
</article>
))}
</div>
</div>
</div>
)
}

View file

@ -9,12 +9,13 @@ import { cardState, timelineTotal } from '@/lib/stack'
* far through the track the reader is and writes each card's transform
* straight to the DOM — no React state, so scrolling never re-renders.
*
* `cardRefs` must be a stable array (a `useRef` value, not a fresh literal)
* so the effect does not re-subscribe on every render.
* `cardRefs` must be a `useRef` holding the card elements, so neither the ref
* nor the array inside it changes identity between renders and the effect
* does not re-subscribe.
*/
export function useStackScrub(
trackRef: RefObject<HTMLElement | null>,
cardRefs: RefObject<HTMLElement | null>[],
cardRefs: RefObject<(HTMLElement | null)[]>,
enabled: boolean,
onActive?: (index: number) => void,
) {
@ -36,7 +37,7 @@ export function useStackScrub(
const apply = () => {
const span = track.offsetHeight - window.innerHeight
const p = span <= 0 ? 0 : Math.min(1, Math.max(0, -track.getBoundingClientRect().top / span))
const cards = cardRefs.map((r) => r.current).filter(Boolean) as HTMLElement[]
const cards = cardRefs.current.filter(Boolean) as HTMLElement[]
if (!cards.length) return
const t = p * timelineTotal(cards.length)

View file

@ -369,7 +369,7 @@ git commit -m "Reshape project data to the five photographed projects"
**Interfaces:**
- Consumes: `cardState(index, t)`, `timelineTotal(count)` from `@/lib/stack` (Task 1).
- Produces:
- `useStackScrub(trackRef: RefObject<HTMLElement | null>, cardRefs: RefObject<HTMLElement | null>[], enabled: boolean, onActive?: (index: number) => void): void`
- `useStackScrub(trackRef: RefObject<HTMLElement | null>, cardRefs: RefObject<(HTMLElement | null)[]>, enabled: boolean, onActive?: (index: number) => void): void`
- `useReducedMotion(): boolean`
- [ ] **Step 1: Write the reduced-motion hook**
@ -415,12 +415,12 @@ import { cardState, timelineTotal } from '@/lib/stack'
* far through the track the reader is and writes each card's transform
* straight to the DOM — no React state, so scrolling never re-renders.
*
* `cardRefs` must be a stable array (a `useRef` value, not a fresh literal)
* so the effect does not re-subscribe on every render.
* `cardRefs` must hold the card elements in a `useRef` array, so the effect
* does not re-subscribe on every render.
*/
export function useStackScrub(
trackRef: RefObject<HTMLElement | null>,
cardRefs: RefObject<HTMLElement | null>[],
cardRefs: RefObject<(HTMLElement | null)[]>,
enabled: boolean,
onActive?: (index: number) => void,
) {
@ -442,7 +442,7 @@ export function useStackScrub(
const apply = () => {
const span = track.offsetHeight - window.innerHeight
const p = span <= 0 ? 0 : Math.min(1, Math.max(0, -track.getBoundingClientRect().top / span))
const cards = cardRefs.map((r) => r.current).filter(Boolean) as HTMLElement[]
const cards = cardRefs.current.filter(Boolean) as HTMLElement[]
if (!cards.length) return
const t = p * timelineTotal(cards.length)