Horizontal Pin Gallery

Vertical scroll drives a pinned horizontal track of large project cards, with inner parallax and a rolling counter.

  • Scroll & Hover Effects
  • Architecture
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (position: sticky). Reduced motion turns the track into a native snap scroller.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/horizontal-pin-gallery.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/horizontal-pin-gallery.tsx
1/**
2 * HorizontalPinGallery — vertical scroll drives a horizontal track. The
3 * 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 a
5 * 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 is
15 * computed from the track width so the scroll distance always matches.
16 * Accessibility: cards are articles in DOM order; the track is reachable by
17 * 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 widths
98 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 centred
130 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 <article
193 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 <div
204 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 planes
313 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 &amp; 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 Scroll
423 </p>
424 </section>
425 <HorizontalPinGallery
426 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`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.