Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Vertical scroll drives a pinned horizontal track of large project cards, with inner parallax and a rolling counter.
Browser support · All evergreen browsers (position: sticky). Reduced motion turns the track into a native snap scroller.
npx shadcn@latest add https://freedesignmd.com/r/horizontal-pin-gallery.json1/**2 * HorizontalPinGallery — vertical scroll drives a horizontal track. The3 * section pins (position: sticky) while a row of large cards slides sideways;4 * each card's artwork counter-moves inside its frame for parallax, and a5 * counter + progress bar track the active card.6 *7 * Props:8 * - items: { title, meta, year, art: ReactNode, caption? }[]9 * - heading: ReactNode shown in the pinned header (left).10 * - parallax: px of inner art travel per card (default 80).11 * - scrollContainer: optional ref; otherwise nearest scrollable ancestor.12 * CSS vars: --fd-hpg-bg, --fd-hpg-ink, --fd-hpg-card-w, --fd-hpg-gap.13 *14 * Browser support: evergreen (sticky + transforms). The section height is15 * computed from the track width so the scroll distance always matches.16 * Accessibility: cards are articles in DOM order; the track is reachable by17 * keyboard (Tab focuses each card and scrolls it into view). Reduced motion:18 * no pinning — the track becomes a native horizontal scroller with snap.19 */20import { useEffect, useId, useRef, useState } from "react";21import type { CSSProperties, ReactNode, RefObject } from "react";22 23export type GalleryItem = { title: string; meta: string; year: string; art: ReactNode; caption?: string };24 25export interface HorizontalPinGalleryProps {26 items: GalleryItem[];27 heading?: ReactNode;28 parallax?: number;29 scrollContainer?: RefObject<HTMLElement | null>;30 className?: string;31 style?: CSSProperties;32}33 34const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);35 36function findScrollParent(el: HTMLElement): HTMLElement | null {37 let p = el.parentElement;38 while (p && p !== document.body && p !== document.documentElement) {39 const oy = getComputedStyle(p).overflowY;40 if ((oy === "auto" || oy === "scroll" || oy === "overlay") && p.scrollHeight > p.clientHeight + 1) return p;41 p = p.parentElement;42 }43 return null;44}45 46export function HorizontalPinGallery({ items, heading, parallax = 80, scrollContainer, className, style }: HorizontalPinGalleryProps) {47 const root = useRef<HTMLElement>(null);48 const track = useRef<HTMLDivElement>(null);49 const arts = useRef<(HTMLDivElement | null)[]>([]);50 const cards = useRef<(HTMLElement | null)[]>([]);51 const bar = useRef<HTMLSpanElement>(null);52 const [idx, setIdx] = useState(0);53 const [reduced, setReduced] = useState(false);54 55 useEffect(() => {56 const el = root.current;57 const tr = track.current;58 if (!el || !tr) return;59 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {60 setReduced(true);61 return;62 }63 const sc = scrollContainer?.current ?? findScrollParent(el);64 const target: HTMLElement | Window = sc ?? window;65 let dist = 0;66 let vh = 0;67 let vw = 0;68 let cardW = 0;69 let offsets: number[] = [];70 let raf = 0;71 let visible = true;72 let shown = -1;73 let lastT = 0;74 let cur = -1;75 76 const measure = () => {77 vh = sc ? sc.clientHeight : window.innerHeight;78 vw = tr.parentElement ? tr.parentElement.clientWidth : window.innerWidth;79 el.style.setProperty("--fd-hpg-vw", `${vw}px`);80 dist = Math.max(0, tr.scrollWidth - vw);81 el.style.height = `${Math.round(vh + dist)}px`;82 el.style.setProperty("--fd-hpg-vh", `${vh}px`);83 offsets = cards.current.map((c) => (c ? c.offsetLeft : 0));84 cardW = cards.current[0]?.offsetWidth ?? 1;85 };86 const progress = () => {87 const r = el.getBoundingClientRect();88 const top = sc ? r.top - sc.getBoundingClientRect().top : r.top;89 return dist > 0 ? clamp(-top / dist) : 0;90 };91 const render = (p: number) => {92 const x = -p * dist;93 tr.style.transform = `translate3d(${x.toFixed(2)}px,0,0)`;94 for (let i = 0; i < arts.current.length; i++) {95 const a = arts.current[i];96 if (!a) continue;97 // card centre relative to viewport centre, in card widths98 const c = (offsets[i] + x + cardW / 2 - vw / 2) / (cardW || 1);99 if (c < -2.5 || c > 2.5) continue;100 a.style.transform = `translate3d(${(-c * parallax).toFixed(2)}px,0,0) scale(1.18)`;101 }102 if (bar.current) bar.current.style.transform = `scaleX(${p.toFixed(4)})`;103 const k = Math.round(p * (items.length - 1));104 if (k !== cur) {105 cur = k;106 setIdx(k);107 }108 };109 const tick = (t: number) => {110 const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016);111 lastT = t;112 const goal = progress();113 if (shown < 0) shown = goal;114 shown += (goal - shown) * (1 - Math.exp(-dt * 12));115 if (Math.abs(goal - shown) < 0.0003) shown = goal;116 render(shown);117 raf = shown !== goal ? requestAnimationFrame(tick) : 0;118 };119 const kick = () => {120 if (!raf && visible) {121 lastT = performance.now();122 raf = requestAnimationFrame(tick);123 }124 };125 const onResize = () => {126 measure();127 render(shown < 0 ? progress() : shown);128 };129 // keyboard: focusing a card scrolls the page so that card is centred130 const onFocus = (e: FocusEvent) => {131 const i = cards.current.indexOf(e.target as HTMLElement);132 if (i < 0 || dist <= 0) return;133 const p = clamp((offsets[i] + cardW / 2 - vw / 2) / dist);134 const r = el.getBoundingClientRect();135 const top = sc ? r.top - sc.getBoundingClientRect().top : r.top;136 const y = (sc ? sc.scrollTop : window.scrollY) + top + p * dist;137 tr.parentElement?.scrollTo?.(0, 0);138 if (sc) sc.scrollTo({ top: y });139 else window.scrollTo({ top: y });140 };141 142 measure();143 shown = progress();144 render(shown);145 const io = new IntersectionObserver(146 ([e]) => {147 visible = e.isIntersecting;148 if (visible) kick();149 },150 { root: sc ?? null },151 );152 io.observe(el);153 const ro = new ResizeObserver(onResize);154 ro.observe(tr);155 target.addEventListener("scroll", kick, { passive: true });156 window.addEventListener("resize", onResize);157 tr.addEventListener("focusin", onFocus);158 return () => {159 io.disconnect();160 ro.disconnect();161 target.removeEventListener("scroll", kick);162 window.removeEventListener("resize", onResize);163 tr.removeEventListener("focusin", onFocus);164 if (raf) cancelAnimationFrame(raf);165 el.style.height = "";166 };167 }, [scrollContainer, parallax, items.length]);168 169 const n = String(items.length).padStart(2, "0");170 return (171 <section ref={root} className={`fd-hpg${className ? " " + className : ""}`} data-reduced={reduced ? "" : undefined} style={style}>172 <style>{css}</style>173 <div className="fd-hpg-pin">174 <header className="fd-hpg-head">175 <div className="fd-hpg-heading">{heading}</div>176 <div className="fd-hpg-count" aria-live="polite">177 <span className="fd-hpg-count-now">178 <span className="fd-hpg-roll" style={{ transform: `translate3d(0,${-idx}em,0)` }}>179 {items.map((_, i) => (180 <span key={i} className="fd-hpg-roll-n">181 {String(i + 1).padStart(2, "0")}182 </span>183 ))}184 </span>185 </span>186 <span className="fd-hpg-count-of">/ {n}</span>187 </div>188 </header>189 <div className="fd-hpg-viewport">190 <div ref={track} className="fd-hpg-track">191 {items.map((it, i) => (192 <article193 key={it.title}194 ref={(node) => {195 cards.current[i] = node;196 }}197 className="fd-hpg-card"198 tabIndex={0}199 data-on={i === idx ? "" : undefined}200 aria-label={`${it.title}, ${it.meta}, ${it.year}`}201 >202 <div className="fd-hpg-frame">203 <div204 ref={(node) => {205 arts.current[i] = node;206 }}207 className="fd-hpg-art"208 aria-hidden="true"209 >210 {it.art}211 </div>212 {it.caption && <span className="fd-hpg-cap">{it.caption}</span>}213 </div>214 <div className="fd-hpg-info">215 <span className="fd-hpg-i">{String(i + 1).padStart(2, "0")}</span>216 <h3 className="fd-hpg-title">{it.title}</h3>217 <span className="fd-hpg-meta">{it.meta}</span>218 <span className="fd-hpg-year">{it.year}</span>219 </div>220 </article>221 ))}222 </div>223 </div>224 <div className="fd-hpg-foot" aria-hidden="true">225 <span className="fd-hpg-bar">226 <span ref={bar} className="fd-hpg-bar-fill" />227 </span>228 </div>229 </div>230 </section>231 );232}233 234const css = `235.fd-hpg{236 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-ease-in-out:cubic-bezier(0.65,0,0.35,1);237 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;238 --fd-hpg-bg:#ECE8E1;--fd-hpg-ink:#171612;--fd-hpg-muted:#6E6A60;239 --fd-hpg-card-w:min(calc(var(--fd-hpg-vw,100vw) * .62),820px);--fd-hpg-gap:clamp(16px,2.4vw,40px);240 --fd-hpg-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;241 --fd-hpg-display:"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter Display","Inter","Segoe UI",Roboto,sans-serif;242 --fd-hpg-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;243 position:relative;display:block;margin:0;padding:0;background:var(--fd-hpg-bg);color:var(--fd-hpg-ink);244}245.fd-hpg,.fd-hpg *,.fd-hpg *::before,.fd-hpg *::after{box-sizing:border-box}246.fd-hpg-pin{247 position:sticky;top:0;height:var(--fd-hpg-vh,100vh);display:grid;grid-template-rows:auto minmax(0,1fr) auto;grid-template-columns:minmax(0,1fr);overflow:hidden;248 margin:0;padding:clamp(20px,4vh,40px) 0 clamp(16px,3vh,28px);249}250.fd-hpg-head{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin:0;padding:0 clamp(16px,4vw,56px) clamp(14px,2.6vh,28px)}251.fd-hpg-heading{min-width:0;margin:0;padding:0}252.fd-hpg-count{display:inline-flex;align-items:baseline;gap:8px;flex:none;margin:0;padding:0;font:500 0.875rem/1 var(--fd-hpg-mono);color:var(--fd-hpg-muted)}253.fd-hpg-count-now{position:relative;display:inline-block;height:1em;overflow:hidden;color:var(--fd-hpg-ink);font:600 clamp(2rem,4vw,3rem)/1 var(--fd-hpg-display);letter-spacing:-0.03em;font-variant-numeric:tabular-nums}254.fd-hpg-roll{display:flex;flex-direction:column;transition:transform var(--fd-dur-3) var(--fd-ease-out)}255.fd-hpg-roll-n{display:block;height:1em}256.fd-hpg-viewport{position:relative;min-height:0;min-width:0;margin:0;padding:0;overflow:hidden}257.fd-hpg-track{258 display:flex;gap:var(--fd-hpg-gap);height:100%;width:max-content;margin:0;259 padding:0 calc((var(--fd-hpg-vw,100vw) - var(--fd-hpg-card-w)) / 2);will-change:transform;260}261.fd-hpg-card{262 position:relative;display:flex;flex-direction:column;gap:14px;width:var(--fd-hpg-card-w);height:100%;margin:0;padding:0;outline:none;263}264.fd-hpg-card:focus-visible .fd-hpg-frame{outline:2px solid rgba(0,122,255,.7);outline-offset:3px}265.fd-hpg-frame{266 position:relative;flex:1;min-height:0;margin:0;overflow:hidden;border-radius:20px;background:#CFC8BC;267 transform:scale(.94);transition:transform var(--fd-dur-4) var(--fd-ease-out);268}269.fd-hpg-card[data-on] .fd-hpg-frame{transform:none}270.fd-hpg-art{position:absolute;inset:0;margin:0;will-change:transform;transform:scale(1.18)}271.fd-hpg-art > *{display:block;width:100%;height:100%}272.fd-hpg-cap{273 position:absolute;left:16px;bottom:14px;padding:7px 10px;border-radius:9999px;274 background:rgba(23,22,18,.55);color:#F4F1EA;font:500 0.6875rem/1 var(--fd-hpg-mono);letter-spacing:.04em;275 -webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);276}277.fd-hpg-info{278 display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:4px 16px;margin:0;padding:0 2px;279 opacity:.45;transition:opacity var(--fd-dur-3) var(--fd-ease-out);280}281.fd-hpg-card[data-on] .fd-hpg-info{opacity:1}282.fd-hpg-i{color:var(--fd-hpg-muted);font:500 0.75rem/1 var(--fd-hpg-mono)}283.fd-hpg-title{margin:0;padding:0;color:var(--fd-hpg-ink);font:600 clamp(1.125rem,1.8vw,1.5rem)/1.15 var(--fd-hpg-display);letter-spacing:-0.02em}284.fd-hpg-year{color:var(--fd-hpg-muted);font:500 0.8125rem/1 var(--fd-hpg-mono)}285.fd-hpg-meta{grid-column:2;color:var(--fd-hpg-muted);font:400 0.875rem/1.35 var(--fd-hpg-sans)}286.fd-hpg-foot{display:flex;justify-content:center;margin:0;padding:clamp(14px,2.6vh,24px) 24px 0}287.fd-hpg-bar{position:relative;display:block;width:min(320px,60%);height:2px;border-radius:9999px;background:rgba(23,22,18,.12);overflow:hidden}288.fd-hpg-bar-fill{position:absolute;inset:0;display:block;background:var(--fd-hpg-ink);transform:scaleX(0);transform-origin:0 50%;will-change:transform}289@media (max-width:640px){290 .fd-hpg{--fd-hpg-card-w:calc(var(--fd-hpg-vw,100vw) * .82);--fd-hpg-gap:12px}291 .fd-hpg-frame{border-radius:16px}292 .fd-hpg-year{display:none}293 .fd-hpg-info{grid-template-columns:auto 1fr}294}295/* reduced motion: no pin, native horizontal scroller */296.fd-hpg[data-reduced] .fd-hpg-pin{position:relative;height:auto}297.fd-hpg[data-reduced] .fd-hpg-viewport{overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory}298.fd-hpg[data-reduced] .fd-hpg-track{height:min(70vh,560px);transform:none !important}299.fd-hpg[data-reduced] .fd-hpg-card{scroll-snap-align:center}300.fd-hpg[data-reduced] .fd-hpg-frame{transform:none}301.fd-hpg[data-reduced] .fd-hpg-art{transform:none !important}302.fd-hpg[data-reduced] .fd-hpg-info{opacity:1}303.fd-hpg[data-reduced] .fd-hpg-foot,.fd-hpg[data-reduced] .fd-hpg-count{display:none}304`;305 306/* ------------------------------------------------------------------ Demo */307 308type Scene = { sky: [string, string]; ground: string; ink: string; sun: string; kind: "tower" | "pavilion" | "bridge" | "house" | "library" | "chapel" };309 310function Building({ s }: { s: Scene }) {311 const gid = `fd-hpg-sky${useId().replace(/:/g, "")}`;312 // simple orthographic architecture: concrete masses, glazing lines, shadow planes313 const ink = s.ink;314 const body =315 s.kind === "tower" ? (316 <g>317 <rect x="430" y="90" width="150" height="420" fill={ink} />318 <rect x="580" y="90" width="44" height="420" fill="#000" opacity=".25" />319 {Array.from({ length: 18 }, (_, i) => (320 <rect key={i} x="438" y={104 + i * 22} width="134" height="3" fill="#fff" opacity=".22" />321 ))}322 <rect x="350" y="370" width="80" height="140" fill={ink} opacity=".75" />323 <rect x="624" y="300" width="110" height="210" fill={ink} opacity=".6" />324 </g>325 ) : s.kind === "pavilion" ? (326 <g>327 <path d="M200 330 L 800 300 L 800 316 L 200 346 Z" fill={ink} />328 {[260, 380, 500, 620, 740].map((x) => (329 <rect key={x} x={x} y={330 - (x - 200) * 0.05} width="8" height={180 + (x - 200) * 0.05} fill={ink} opacity=".85" />330 ))}331 <rect x="290" y="380" width="420" height="130" fill="#fff" opacity=".18" />332 <path d="M200 346 L 800 316 L 800 330 L 200 360 Z" fill="#000" opacity=".2" />333 </g>334 ) : s.kind === "bridge" ? (335 <g>336 <path d="M0 360 L 1000 330 L 1000 352 L 0 382 Z" fill={ink} />337 <path d="M120 377 C 300 250 500 250 680 355" fill="none" stroke={ink} strokeWidth="10" />338 {Array.from({ length: 16 }, (_, i) => {339 const x = 150 + i * 34;340 const t = (x - 120) / 560;341 const y = 377 - 4 * t * (1 - t) * 118;342 return <line key={i} x1={x} y1={y} x2={x} y2={372 - (x / 1000) * 30} stroke={ink} strokeWidth="2" opacity=".7" />;343 })}344 <rect x="0" y="420" width="1000" height="200" fill={s.sky[1]} opacity=".5" />345 </g>346 ) : s.kind === "house" ? (347 <g>348 <path d="M300 300 L 470 200 L 640 300 L 640 510 L 300 510 Z" fill={ink} />349 <path d="M640 300 L 470 200 L 520 200 L 700 300 L 700 510 L 640 510 Z" fill="#000" opacity=".3" />350 <rect x="350" y="360" width="110" height="150" fill={s.sun} opacity=".85" />351 <rect x="490" y="330" width="100" height="60" fill="#fff" opacity=".2" />352 </g>353 ) : s.kind === "library" ? (354 <g>355 <rect x="180" y="250" width="640" height="260" fill={ink} />356 {Array.from({ length: 20 }, (_, i) => (357 <rect key={i} x={196 + i * 31} y="270" width="14" height="220" fill="#fff" opacity={0.08 + (i % 3) * 0.06} />358 ))}359 <rect x="180" y="236" width="640" height="14" fill="#000" opacity=".3" />360 </g>361 ) : (362 <g>363 <path d="M420 510 L 420 260 C 420 170 580 170 580 260 L 580 510 Z" fill={ink} />364 <rect x="494" y="180" width="12" height="330" fill={s.sun} opacity=".9" />365 <path d="M580 510 L 580 260 C 580 210 560 190 540 182 C 600 190 640 220 640 270 L 640 510 Z" fill="#000" opacity=".28" />366 </g>367 );368 return (369 <svg viewBox="0 0 1000 620" preserveAspectRatio="xMidYMid slice">370 <defs>371 <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">372 <stop offset="0" stopColor={s.sky[0]} />373 <stop offset="1" stopColor={s.sky[1]} />374 </linearGradient>375 <radialGradient id={`${gid}g`}>376 <stop offset="0" stopColor={s.sun} stopOpacity=".55" />377 <stop offset="1" stopColor={s.sun} stopOpacity="0" />378 </radialGradient>379 <linearGradient id={`${gid}h`} x1="0" y1="0" x2="0" y2="1">380 <stop offset="0" stopColor={s.sky[1]} stopOpacity="0" />381 <stop offset="1" stopColor={s.sky[1]} stopOpacity=".55" />382 </linearGradient>383 <linearGradient id={`${gid}r`} x1="0" y1="0" x2="0" y2="1">384 <stop offset="0" stopColor="#fff" stopOpacity=".14" />385 <stop offset="1" stopColor="#000" stopOpacity=".25" />386 </linearGradient>387 </defs>388 <rect width="1000" height="620" fill={`url(#${gid})`} />389 <circle cx="760" cy="150" r="190" fill={`url(#${gid}g)`} />390 <circle cx="760" cy="150" r="70" fill={s.sun} opacity=".92" />391 <path d="M0 470 C 120 430 220 452 330 436 S 560 404 660 430 S 880 420 1000 440 L 1000 512 L 0 512 Z" fill={s.ink} opacity=".14" />392 <path d="M0 492 C 160 470 300 486 460 474 S 760 466 1000 484 L 1000 512 L 0 512 Z" fill={s.ink} opacity=".1" />393 <rect x="0" y="420" width="1000" height="92" fill={`url(#${gid}h)`} />394 <rect x="0" y="510" width="1000" height="110" fill={s.ground} />395 <rect x="0" y="510" width="1000" height="110" fill={`url(#${gid}r)`} />396 {body}397 <rect x="0" y="510" width="1000" height="2" fill="#000" opacity=".15" />398 </svg>399 );400}401 402const PROJECTS: (Omit<GalleryItem, "art"> & { scene: Scene })[] = [403 { title: "Vesterhavn Tower", meta: "Residential · Aarhus, DK · 31 floors", year: "2025", caption: "CLT hybrid structure", scene: { kind: "tower", sky: ["#F2C9A0", "#E7866A"], ground: "#5A3E33", ink: "#2A2220", sun: "#FFE6B8" } },404 { title: "Salt Pavilion", meta: "Cultural · Algarve, PT · 1,200 m²", year: "2024", caption: "Lime render, cork roof", scene: { kind: "pavilion", sky: ["#CFE3EA", "#8DB6C6"], ground: "#D8CFBE", ink: "#2F3B40", sun: "#FFFFFF" } },405 { title: "Ribe Footbridge", meta: "Infrastructure · Ribe, DK · 140 m span", year: "2023", caption: "Weathering steel", scene: { kind: "bridge", sky: ["#E9E4D8", "#B9C2BD"], ground: "#7A8A84", ink: "#3A3631", sun: "#F7EFD8" } },406 { title: "Casa Encosta", meta: "Private house · Sintra, PT · 280 m²", year: "2023", caption: "Board-formed concrete", scene: { kind: "house", sky: ["#2C3550", "#7E6C8F"], ground: "#2B2A33", ink: "#16161D", sun: "#F7B866" } },407 { title: "Nordlys Library", meta: "Civic · Tromsø, NO · 6,400 m²", year: "2022", caption: "Larch fins, triple glazing", scene: { kind: "library", sky: ["#0F2A35", "#2F7A74"], ground: "#0E1A1E", ink: "#0A1418", sun: "#B8F2D8" } },408 { title: "Chapel of Light", meta: "Sacred · Lleida, ES · 180 m²", year: "2021", caption: "Single slot skylight", scene: { kind: "chapel", sky: ["#F4E7D2", "#D9B892"], ground: "#B08F6A", ink: "#5C4632", sun: "#FFF5DE" } },409];410 411export function Demo() {412 const items: GalleryItem[] = PROJECTS.map(({ scene, ...p }) => ({ ...p, art: <Building s={scene} /> }));413 return (414 <div className="fd-hpg-demo">415 <style>{demoCss}</style>416 <section className="fd-hpg-demo-intro">417 <span className="fd-hpg-demo-logo">Atelier Holm & Reis</span>418 <h1 className="fd-hpg-demo-h">Buildings that age well.</h1>419 <p className="fd-hpg-demo-p">An architecture practice in Copenhagen and Lisbon. Six recent projects, scroll to walk through them.</p>420 <p className="fd-hpg-demo-cue" aria-hidden="true">421 <span className="fd-hpg-demo-cue-line" />422 Scroll423 </p>424 </section>425 <HorizontalPinGallery426 items={items}427 heading={428 <>429 <span className="fd-hpg-demo-k">Selected work</span>430 <span className="fd-hpg-demo-t">2021 — 2025</span>431 </>432 }433 />434 <section className="fd-hpg-demo-outro">435 <p className="fd-hpg-demo-p">38 more projects in the archive →</p>436 </section>437 </div>438 );439}440 441const demoCss = `442.fd-hpg-demo{display:block;margin:0;padding:0;background:#ECE8E1;color:#171612;min-height:100vh}443.fd-hpg-demo,.fd-hpg-demo *{box-sizing:border-box}444.fd-hpg-demo-intro{display:flex;flex-direction:column;justify-content:flex-end;min-height:62vh;max-width:1240px;margin:0 auto;padding:32px clamp(16px,4vw,56px) 48px}445.fd-hpg-demo-logo{margin:0 0 auto;color:#171612;font:600 0.9375rem/1 ui-sans-serif,-apple-system,"Inter",sans-serif;letter-spacing:-0.01em}446.fd-hpg-demo-h{margin:0;padding:0;color:#171612;font:600 clamp(2.75rem,8vw,6.5rem)/.92 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.05em}447.fd-hpg-demo-p{margin:18px 0 0;padding:0;max-width:480px;color:#6E6A60;font:400 1.0625rem/1.45 ui-sans-serif,-apple-system,"Inter",sans-serif}448.fd-hpg-demo-cue{display:inline-flex;align-items:center;gap:10px;margin:28px 0 0;padding:0;color:#6E6A60;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}449.fd-hpg-demo-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(23,22,18,.15)}450.fd-hpg-demo-cue-line::after{content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#171612;animation:fd-hpg-cue 1.6s cubic-bezier(0.65,0,0.35,1) infinite}451@keyframes fd-hpg-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}452.fd-hpg-demo-k{display:block;margin:0 0 6px;color:#6E6A60;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-transform:uppercase}453.fd-hpg-demo-t{display:block;color:#171612;font:600 clamp(1.5rem,3vw,2.25rem)/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.03em}454.fd-hpg-demo-outro{max-width:1240px;margin:0 auto;padding:48px clamp(16px,4vw,56px) 96px}455@media (prefers-reduced-motion: reduce){.fd-hpg-demo-cue-line::after{animation:none}}456`;A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.