From 0c4890c289e241af336cd36d8bfcd7fac0dc0bc8 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 3 Nov 2026 18:04:33 +0200 Subject: [PATCH] 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. --- app/src/components/Intro.tsx | 57 +++++++++++++- app/src/components/LayerAccordion.tsx | 57 ++++++++++++++ app/src/components/Services.tsx | 104 +++++--------------------- app/src/data/content.ts | 30 ++++++++ 4 files changed, 161 insertions(+), 87 deletions(-) create mode 100644 app/src/components/LayerAccordion.tsx diff --git a/app/src/components/Intro.tsx b/app/src/components/Intro.tsx index d9e3dd4..ba3adf8 100644 --- a/app/src/components/Intro.tsx +++ b/app/src/components/Intro.tsx @@ -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(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 ( + + {text} + {suffix} + + ) +} + export function Intro() { return (
@@ -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' : ''}`} >
{f.label}
-
{f.value}
+
+ {f.value} + +
{f.label}
))} diff --git a/app/src/components/LayerAccordion.tsx b/app/src/components/LayerAccordion.tsx new file mode 100644 index 0000000..eca9e4b --- /dev/null +++ b/app/src/components/LayerAccordion.tsx @@ -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 ( +
+ {/* Photograph */} +
+ + + {/* Scrim, so the copy stays legible over the photograph */} +
+ ) +} + +export function LayerAccordion() { + return ( +
+ {layers.map((layer) => ( + + ))} +
+ ) +} diff --git a/app/src/components/Services.tsx b/app/src/components/Services.tsx index 558f7a0..b81859b 100644 --- a/app/src/components/Services.tsx +++ b/app/src/components/Services.tsx @@ -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 (
Services -

- One contractor. Every layer of the site. +

+ Five layers. None of them optional.

- 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.

-
- {/* Sticky image (desktop) */} -
-
- {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 ? ( - - ) : ( - - ) - })} -
-
- {String(active + 1).padStart(2, '0')} - {services[active].title} -
-
-
- - {/* Service list */} -
    - {services.map((s, i) => { - const open = i === active - return ( -
  • - -
    -
    -
    - {s.video ? ( - - ) : ( - - )} -

    {s.body}

    -
      - {s.scope.map((t) => ( -
    • - {t} -
    • - ))} -
    -
    -
    -
    -
  • - ) - })} -
+
+ +

+ Civil design and residential development are also in-house.{' '} + + Tell us what you need + + . +

diff --git a/app/src/data/content.ts b/app/src/data/content.ts index c789d35..66710a9 100644 --- a/app/src/data/content.ts +++ b/app/src/data/content.ts @@ -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 = { 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',