Add the stacked project deck component
This commit is contained in:
parent
2c7a16dccb
commit
6373339c3f
3 changed files with 135 additions and 9 deletions
125
app/src/components/ProjectStack.tsx
Normal file
125
app/src/components/ProjectStack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue