Replace the services list with the five-layer accordion

Ports the Stackless Coffee ritual accordion: five slices that expand
across the row on hover or keyboard focus, driven by pure CSS
flex-grow. The layer copy is derived from the existing services array,
so the two cannot drift apart.
This commit is contained in:
David 2026-11-03 18:04:33 +02:00
parent 59e659d5ef
commit 0c4890c289
4 changed files with 161 additions and 87 deletions

View file

@ -1,6 +1,58 @@
import { useEffect, useRef, useState } from 'react'
import { facts } from '@/data/content'
import { useReducedMotion } from '@/hooks/useReducedMotion'
import { Reveal } from './Reveal'
// Counts a fact's number up from 0 once it scrolls into view.
// "14 000 m²" -> animates 14 000, keeps the " m²" suffix. Non-numeric values render as-is.
function CountUp({ value, delay = 0 }: { value: string; delay?: number }) {
const match = value.match(/^([\d ]+)(.*)$/)
const target = match ? parseInt(match[1].replace(/\s/g, ''), 10) : NaN
const suffix = match ? match[2] : ''
const reduced = useReducedMotion()
const ref = useRef<HTMLElement>(null)
const [n, setN] = useState(reduced ? target : 0)
useEffect(() => {
const el = ref.current
if (!el || reduced || Number.isNaN(target)) return
let raf = 0
let timer = 0
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return
io.disconnect()
const duration = 1800
timer = window.setTimeout(() => {
const t0 = performance.now()
const tick = (now: number) => {
const p = Math.min((now - t0) / duration, 1)
setN(Math.round(target * (1 - Math.pow(1 - p, 4))))
if (p < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
}, delay)
},
{ rootMargin: '0px 0px -10% 0px' },
)
io.observe(el)
return () => {
io.disconnect()
clearTimeout(timer)
cancelAnimationFrame(raf)
}
}, [target, reduced, delay])
if (Number.isNaN(target)) return <>{value}</>
const text = String(reduced ? target : n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return (
<span ref={ref} className="tabular-nums" aria-hidden>
{text}
{suffix}
</span>
)
}
export function Intro() {
return (
<section className="bg-bone py-24 text-ink sm:py-32 lg:py-40" aria-label="About Mega Infrastructure">
@ -20,7 +72,10 @@ export function Intro() {
className={`border-b border-ink/15 py-8 pr-6 lg:border-b-0 lg:py-10 ${i % 2 === 1 ? 'pl-6 lg:pl-8' : ''} ${i > 0 ? 'lg:border-l lg:pl-8' : ''}`}
>
<dt className="sr-only">{f.label}</dt>
<dd className="display-wide text-[clamp(2.2rem,4vw,3.6rem)] font-medium leading-none">{f.value}</dd>
<dd className="display-wide text-[clamp(2.2rem,4vw,3.6rem)] font-medium leading-none">
<span className="sr-only">{f.value}</span>
<CountUp value={f.value} delay={i * 100 + 200} />
</dd>
<dd className="mt-4 max-w-[220px] text-sm leading-relaxed text-ink/60">{f.label}</dd>
</Reveal>
))}

View file

@ -0,0 +1,57 @@
import { img, layers, type Layer } from '@/data/content'
// One slice of the accordion. On desktop the row is a flex line and the slice
// grows on hover or keyboard focus; on mobile it is a full-width card with
// the copy always visible.
function Slice({ layer }: { layer: Layer }) {
return (
<article
tabIndex={0}
aria-label={layer.title}
className="group relative flex flex-col overflow-hidden sm:block sm:min-w-0 sm:flex-1 sm:transition-[flex-grow] sm:duration-[800ms] sm:ease-expo sm:hover:flex-grow-[4.2] sm:focus-within:flex-grow-[4.2]"
>
{/* Photograph */}
<div className="relative h-44 shrink-0 sm:absolute sm:inset-0 sm:h-auto">
<div
className="absolute inset-0 bg-cover bg-center transition-[transform,filter] duration-[1200ms] ease-expo sm:scale-[1.08] sm:saturate-[0.55] sm:brightness-[0.42] sm:group-hover:scale-[1.02] sm:group-hover:saturate-100 sm:group-hover:brightness-[0.72]"
style={{ backgroundImage: `url(${img(layer.image, true)})` }}
aria-hidden="true"
/>
</div>
{/* Scrim, so the copy stays legible over the photograph */}
<div className="absolute inset-0 hidden bg-gradient-to-t from-ink via-ink/55 to-ink/15 sm:block" aria-hidden="true" />
{/* Vertical title, desktop only */}
<span className="absolute inset-x-0 bottom-0 hidden p-[clamp(1rem,2vw,1.75rem)] font-display text-[clamp(1.1rem,1.7vw,1.6rem)] font-medium leading-none text-bone [text-shadow:0_2px_16px_rgba(12,12,10,0.9)] [writing-mode:vertical-rl] rotate-180 transition-colors duration-500 group-hover:text-gold sm:block">
{layer.title}
</span>
{/* Panel */}
<div className="relative flex flex-col justify-end p-6 sm:absolute sm:inset-0 sm:p-[clamp(1.4rem,3vw,2.5rem)] sm:opacity-0 sm:translate-y-6 sm:transition-[opacity,transform] sm:duration-700 sm:ease-expo sm:group-hover:opacity-100 sm:group-hover:translate-y-0 sm:group-focus-within:opacity-100 sm:group-focus-within:translate-y-0">
<p className="eyebrow text-gold">{layer.kicker}</p>
<h3 className="display mt-3 text-[clamp(1.5rem,2.6vw,2.4rem)] font-normal leading-[0.98] tracking-[-0.02em] text-bone">
{layer.title}
</h3>
<p className="mt-4 max-w-[34ch] text-[0.97rem] leading-relaxed text-bone/75">{layer.body}</p>
<ul className="mt-5 flex flex-wrap gap-2">
{layer.scope.map((t) => (
<li key={t} className="rounded-full border border-bone/20 px-3 py-1 text-xs text-bone/70">
{t}
</li>
))}
</ul>
</div>
</article>
)
}
export function LayerAccordion() {
return (
<div className="flex flex-col sm:h-[clamp(22rem,58vh,34rem)] sm:flex-row sm:overflow-hidden sm:border-y sm:border-bone/12">
{layers.map((layer) => (
<Slice key={layer.id} layer={layer} />
))}
</div>
)
}

View file

@ -1,106 +1,38 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { img, pillars, services } from '@/data/content'
import { LoopVideo } from './LoopVideo'
import { pillars } from '@/data/content'
import { LayerAccordion } from './LayerAccordion'
import { Reveal, SectionLabel } from './Reveal'
export function Services() {
const [active, setActive] = useState(0)
return (
<section id="services" className="scroll-mt-20 bg-ink py-24 sm:py-32 lg:py-40">
<div className="container-x">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end">
<Reveal>
<SectionLabel index="01">Services</SectionLabel>
<h2 className="display mt-8 max-w-[760px] text-[clamp(2.2rem,5vw,4.6rem)] font-light leading-[1.02]">
One contractor. <span className="text-bone/45">Every layer of the site.</span>
<h2 className="display mt-8 max-w-[820px] text-[clamp(2.2rem,5vw,4.6rem)] font-light leading-[1.02]">
Five layers. <span className="text-gold">None of them optional.</span>
</h2>
</Reveal>
<Reveal delay={150}>
<p className="max-w-[420px] leading-relaxed text-bone/60">
From design and planning through to construction and project management, our civil construction services cover
every part of the process, and the merger brought residential and electrical in-house too.
This is the whole method, start to finish. Five layers on every site, in the same order, since 2007 — and it is not
going to start now.
</p>
</Reveal>
</div>
<div className="mt-16 grid gap-10 lg:mt-24 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16">
{/* Sticky image (desktop) */}
<div className="relative hidden lg:block">
<div className="sticky top-28 aspect-[4/5] overflow-hidden rounded-sm bg-coal">
{services.map((s, i) => {
const cls = `absolute inset-0 h-full w-full object-cover transition-[opacity,transform] duration-[1200ms] ease-expo ${
i === active ? 'scale-100 opacity-100' : 'scale-105 opacity-0'
}`
return s.video ? (
<LoopVideo key={s.id} src={s.video} poster={img(s.image)} active={i === active} className={cls} />
) : (
<img key={s.id} src={img(s.image)} alt="" loading="lazy" className={cls} />
)
})}
<div className="absolute inset-0 bg-gradient-to-t from-ink/70 via-transparent to-transparent" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-8">
<span className="display-wide text-7xl font-semibold text-bone/90">{String(active + 1).padStart(2, '0')}</span>
<span className="eyebrow text-bone/70">{services[active].title}</span>
</div>
</div>
</div>
{/* Service list */}
<ul className="border-t border-bone/15">
{services.map((s, i) => {
const open = i === active
return (
<li key={s.id} id={`service-${s.id}`} className="border-b border-bone/15">
<button
type="button"
onClick={() => setActive(i)}
onMouseEnter={() => window.matchMedia('(hover: hover)').matches && setActive(i)}
aria-expanded={open}
className="group flex w-full items-center gap-6 py-7 text-left sm:py-8"
>
<span className={`eyebrow w-8 shrink-0 transition-colors ${open ? 'text-gold' : 'text-bone/40'}`}>
{String(i + 1).padStart(2, '0')}
</span>
<span
className={`display flex-1 text-[clamp(1.5rem,3vw,2.6rem)] font-light leading-tight transition-colors duration-300 ${
open ? 'text-bone' : 'text-bone/55 group-hover:text-bone/85'
}`}
>
{s.title}
</span>
<Plus
size={20}
strokeWidth={1.5}
className={`shrink-0 transition-transform duration-500 ease-expo ${open ? 'rotate-45 text-gold' : 'text-bone/50'}`}
/>
</button>
<div
className={`grid transition-[grid-template-rows] duration-700 ease-expo ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
>
<div className="overflow-hidden">
<div className="pb-9 pl-14">
{s.video ? (
<LoopVideo src={s.video} poster={img(s.image, true)} active={open} className="mb-6 aspect-[16/10] w-full rounded-sm object-cover lg:hidden" />
) : (
<img src={img(s.image, true)} alt="" loading="lazy" className="mb-6 aspect-[16/10] w-full rounded-sm object-cover lg:hidden" />
)}
<p className="max-w-[560px] leading-relaxed text-bone/70">{s.body}</p>
<ul className="mt-6 flex flex-wrap gap-2">
{s.scope.map((t) => (
<li key={t} className="rounded-full border border-bone/15 px-3.5 py-1.5 text-xs text-bone/70">
{t}
</li>
))}
</ul>
</div>
</div>
</div>
</li>
)
})}
</ul>
<div className="mt-14 lg:mt-20">
<LayerAccordion />
<p className="mt-8 max-w-[520px] text-sm leading-relaxed text-bone/45">
Civil design and residential development are also in-house.{' '}
<a
href="#contact"
className="text-gold underline decoration-gold/40 underline-offset-4 transition-colors hover:text-bone"
>
Tell us what you need
</a>
.
</p>
</div>
</div>
</section>

View file

@ -161,6 +161,36 @@ export const services: Service[] = [
},
]
export type Layer = {
id: string
kicker: string
title: string
body: string
scope: string[]
image: string
}
// The five layers every site is built from, in the order they go into the
// ground. `services` stays the full list the quote form offers.
const layerIds = ['earthworks', 'municipal', 'stormwater', 'roads', 'electrical'] as const
const layerTitles: Record<string, string> = { municipal: 'Water & Sewer', roads: 'Roads & Paving' }
const layerKickers = ['Layer one', 'Layer two', 'Layer three', 'Layer four', 'Layer five']
export const layers: Layer[] = layerIds.flatMap((id, i) => {
const s = services.find((x) => x.id === id)
if (!s) return []
return [
{
id: s.id,
kicker: layerKickers[i],
title: layerTitles[s.id] ?? s.title,
body: s.short,
scope: s.scope,
image: s.image,
},
]
})
export const pillars = [
{
title: 'Commitment to Safety',