Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Apple-style zoom-out: a full-bleed hero shrinks into a seven-tile mosaic as the surrounding tiles drift into place.
Browser support · All evergreen browsers (position: sticky, transforms). Reduced motion shows the settled mosaic.
npx shadcn@latest add https://freedesignmd.com/r/zoom-parallax.json1/**2 * ZoomParallax — Apple-style "zoom out" scroll scene. A full-bleed hero tile3 * starts filling the viewport; as you scroll, it shrinks into the centre of a4 * 7-tile mosaic while the six surrounding tiles scale and drift into place.5 * Text overlays fade out as the grid settles, a caption fades in.6 *7 * Props:8 * - tiles: exactly 7 ReactNodes; tiles[0] is the hero (centre) tile.9 * - overlay: ReactNode shown on the hero at the start (fades out).10 * - caption: ReactNode shown under the settled grid.11 * - length: scroll length in viewport heights (default 2.6).12 * - scrollContainer: optional ref; otherwise nearest scrollable ancestor.13 * CSS vars: --fd-zpx-bg, --fd-zpx-gap, --fd-zpx-radius.14 *15 * Layout: 4×3 mosaic on wide stages (hero spans 2×2), 2×4 under 640px.16 * Each tile's final rect is measured once per resize; per frame, only17 * transforms are written (FLIP-style inverse scale from the full-bleed rect).18 * Browser support: evergreen (sticky + transforms).19 * Accessibility: tiles are aria-hidden art unless you pass labelled content;20 * overlay/caption text is real text. Reduced motion shows the settled grid.21 */22import { useEffect, useId, useRef } from "react";23import type { CSSProperties, ReactNode, RefObject } from "react";24 25export interface ZoomParallaxProps {26 tiles: ReactNode[];27 overlay?: ReactNode;28 caption?: ReactNode;29 length?: number;30 scrollContainer?: RefObject<HTMLElement | null>;31 className?: string;32 style?: CSSProperties;33}34 35const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);36const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);37const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);38 39function findScrollParent(el: HTMLElement): HTMLElement | null {40 let p = el.parentElement;41 while (p && p !== document.body && p !== document.documentElement) {42 const oy = getComputedStyle(p).overflowY;43 if ((oy === "auto" || oy === "scroll" || oy === "overlay") && p.scrollHeight > p.clientHeight + 1) return p;44 p = p.parentElement;45 }46 return null;47}48 49export function ZoomParallax({ tiles, overlay, caption, length = 2.6, scrollContainer, className, style }: ZoomParallaxProps) {50 const root = useRef<HTMLElement>(null);51 const stage = useRef<HTMLDivElement>(null);52 const cells = useRef<(HTMLDivElement | null)[]>([]);53 const inners = useRef<(HTMLDivElement | null)[]>([]);54 const ov = useRef<HTMLDivElement>(null);55 const cap = useRef<HTMLDivElement>(null);56 57 useEffect(() => {58 const el = root.current;59 const st = stage.current;60 if (!el || !st) return;61 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {62 el.dataset.reduced = "";63 return;64 }65 const sc = scrollContainer?.current ?? findScrollParent(el);66 const target: HTMLElement | Window = sc ?? window;67 if (sc) el.dataset.contained = "";68 69 type Geo = { cx: number; cy: number; w: number; h: number };70 let geo: Geo[] = [];71 let S = { w: 0, h: 0 };72 let vh = 0;73 let raf = 0;74 let visible = true;75 let shown = -1;76 let lastT = 0;77 78 const measure = () => {79 vh = sc ? sc.clientHeight : window.innerHeight;80 if (sc) el.style.setProperty("--fd-zpx-vh", `${vh}px`);81 // read final layout with transforms cleared82 cells.current.forEach((c) => c && (c.style.transform = "none"));83 const sr = st.getBoundingClientRect();84 S = { w: sr.width, h: sr.height };85 geo = cells.current.map((c) => {86 if (!c) return { cx: 0, cy: 0, w: 1, h: 1 };87 const r = c.getBoundingClientRect();88 return { cx: r.left - sr.left + r.width / 2, cy: r.top - sr.top + r.height / 2, w: r.width, h: r.height };89 });90 };91 const progress = () => {92 const r = el.getBoundingClientRect();93 const top = sc ? r.top - sc.getBoundingClientRect().top : r.top;94 const range = r.height - vh;95 return range > 0 ? clamp(-top / range) : 0;96 };97 const render = (p: number) => {98 // 0 → .78 : zoom out; .78 → 1 : hold99 const z = ease(clamp(p / 0.78));100 const hero = geo[0];101 if (!hero) return;102 // scale that makes the hero cover the stage103 const cover = Math.max(S.w / hero.w, S.h / hero.h);104 const k = cover + (1 - cover) * z; // current scale of the whole mosaic105 // the mosaic scales around the hero centre, which itself moves to stage centre at start106 const ox = (S.w / 2 - hero.cx) * (1 - z);107 const oy = (S.h / 2 - hero.cy) * (1 - z);108 for (let i = 0; i < geo.length; i++) {109 const c = cells.current[i];110 const g = geo[i];111 if (!c) continue;112 // per-tile stagger: outer tiles lag a bit and drift in (parallax)113 const dx = g.cx - hero.cx;114 const dy = g.cy - hero.cy;115 const lag = i === 0 ? 0 : 0.18 + 0.08 * ((i * 37) % 5) / 4;116 const zi = i === 0 ? z : easeOut(clamp((p / 0.78 - lag) / (1 - lag)));117 const ki = i === 0 ? k : cover * 1.25 + (1 - cover * 1.25) * zi;118 const tx = ox + dx * (k - 1) + dx * (ki - k) * 0.35;119 const ty = oy + dy * (k - 1) + dy * (ki - k) * 0.35;120 c.style.transform = `translate3d(${tx.toFixed(2)}px,${ty.toFixed(2)}px,0) scale(${(i === 0 ? k : ki).toFixed(4)})`;121 c.style.opacity = i === 0 ? "1" : clamp(zi * 1.6).toFixed(3);122 const inner = inners.current[i];123 // counter-scale content slightly so the hero art doesn't blow up linearly124 if (inner) inner.style.transform = `scale(${(1 + (i === 0 ? 0.18 : 0.3) * (1 - zi)).toFixed(4)})`;125 }126 if (ov.current) {127 const o = 1 - clamp(p / 0.28);128 ov.current.style.opacity = o.toFixed(3);129 ov.current.style.transform = `translate3d(0,${(-30 * (1 - o)).toFixed(2)}px,0)`;130 }131 if (cap.current) {132 const o = clamp((p - 0.7) / 0.2);133 cap.current.style.opacity = o.toFixed(3);134 cap.current.style.transform = `translate3d(0,${(16 * (1 - o)).toFixed(2)}px,0)`;135 }136 st.style.setProperty("--fd-zpx-r", `${(28 * (0.15 + 0.85 * z)).toFixed(2)}px`);137 };138 const tick = (t: number) => {139 const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016);140 lastT = t;141 const goal = progress();142 if (shown < 0) shown = goal;143 shown += (goal - shown) * (1 - Math.exp(-dt * 12));144 if (Math.abs(goal - shown) < 0.0003) shown = goal;145 render(shown);146 raf = shown !== goal ? requestAnimationFrame(tick) : 0;147 };148 const kick = () => {149 if (!raf && visible) {150 lastT = performance.now();151 raf = requestAnimationFrame(tick);152 }153 };154 const onResize = () => {155 measure();156 render(shown < 0 ? progress() : shown);157 };158 measure();159 shown = progress();160 render(shown);161 const io = new IntersectionObserver(162 ([e]) => {163 visible = e.isIntersecting;164 if (visible) kick();165 },166 { root: sc ?? null },167 );168 io.observe(el);169 const ro = new ResizeObserver(onResize);170 ro.observe(st);171 target.addEventListener("scroll", kick, { passive: true });172 window.addEventListener("resize", onResize);173 return () => {174 io.disconnect();175 ro.disconnect();176 target.removeEventListener("scroll", kick);177 window.removeEventListener("resize", onResize);178 if (raf) cancelAnimationFrame(raf);179 };180 }, [scrollContainer, tiles.length]);181 182 return (183 <section ref={root} className={`fd-zpx${className ? " " + className : ""}`} style={{ ["--fd-zpx-len" as string]: String(length), ...style }}>184 <style>{css}</style>185 <div className="fd-zpx-pin">186 <div ref={stage} className="fd-zpx-stage">187 <div className="fd-zpx-grid">188 {tiles.slice(0, 7).map((t, i) => (189 <div190 key={i}191 ref={(n) => {192 cells.current[i] = n;193 }}194 className="fd-zpx-cell"195 data-i={i}196 >197 <div198 ref={(n) => {199 inners.current[i] = n;200 }}201 className="fd-zpx-inner"202 >203 {t}204 </div>205 </div>206 ))}207 </div>208 {overlay && (209 <div ref={ov} className="fd-zpx-overlay">210 {overlay}211 </div>212 )}213 {caption && (214 <div ref={cap} className="fd-zpx-caption">215 {caption}216 </div>217 )}218 </div>219 </div>220 </section>221 );222}223 224const css = `225.fd-zpx{226 --fd-zpx-bg:#F5F2EC;--fd-zpx-gap:12px;--fd-zpx-r:28px;227 position:relative;display:block;margin:0;padding:0;background:var(--fd-zpx-bg);228 height:calc(var(--fd-zpx-len) * 100vh);height:calc(var(--fd-zpx-len) * 100svh);229}230.fd-zpx,.fd-zpx *,.fd-zpx *::before,.fd-zpx *::after{box-sizing:border-box}231.fd-zpx[data-contained]{height:calc(var(--fd-zpx-len) * var(--fd-zpx-vh,100vh))}232.fd-zpx-pin{position:sticky;top:0;height:100vh;height:100svh;overflow:hidden;margin:0;padding:0}233.fd-zpx[data-contained] .fd-zpx-pin{height:var(--fd-zpx-vh,100vh)}234.fd-zpx-stage{position:relative;width:100%;height:100%;margin:0;padding:clamp(16px,4vh,40px) clamp(16px,3vw,40px) clamp(64px,12vh,112px)}235.fd-zpx-grid{236 display:grid;width:100%;height:100%;margin:0;padding:0;gap:var(--fd-zpx-gap);237 grid-template-columns:1fr 1.15fr 1.15fr 1fr;grid-template-rows:1fr 1fr 1fr;238 grid-template-areas:"a h h b" "c h h d" "e f f f";239}240.fd-zpx-cell{position:relative;min-width:0;min-height:0;overflow:hidden;border-radius:var(--fd-zpx-r);will-change:transform,opacity;transform-origin:50% 50%;background:#ddd}241.fd-zpx-cell[data-i="0"]{grid-area:h;z-index:2}242.fd-zpx-cell[data-i="1"]{grid-area:a}.fd-zpx-cell[data-i="2"]{grid-area:b}.fd-zpx-cell[data-i="3"]{grid-area:c}243.fd-zpx-cell[data-i="4"]{grid-area:d}.fd-zpx-cell[data-i="5"]{grid-area:e}.fd-zpx-cell[data-i="6"]{grid-area:f}244.fd-zpx-inner{position:absolute;inset:0;will-change:transform}245.fd-zpx-inner > *{display:block;width:100%;height:100%}246.fd-zpx-overlay{position:absolute;inset:0;z-index:5;isolation:isolate;display:flex;flex-direction:column;justify-content:flex-end;pointer-events:none;padding:clamp(24px,6vh,64px) clamp(20px,5vw,72px)}247.fd-zpx-caption{position:absolute;left:0;right:0;bottom:0;z-index:5;display:flex;align-items:center;justify-content:center;height:clamp(64px,12vh,112px);opacity:0;padding:0 16px}248@media (max-width:640px){249 .fd-zpx{--fd-zpx-gap:8px}250 .fd-zpx-grid{grid-template-columns:1fr 1fr;grid-template-rows:.8fr 1.3fr 1.3fr .8fr;grid-template-areas:"a b" "h h" "h h" "e f"}251 .fd-zpx-cell[data-i="3"],.fd-zpx-cell[data-i="4"]{display:none}252 .fd-zpx-cell[data-i="6"]{grid-area:f}253}254.fd-zpx[data-reduced]{height:auto}255.fd-zpx[data-reduced] .fd-zpx-pin{position:relative;height:100vh}256.fd-zpx[data-reduced] .fd-zpx-overlay{display:none}257.fd-zpx[data-reduced] .fd-zpx-caption{opacity:1}258`;259 260/* ------------------------------------------------------------------ Demo */261 262type Palette = { sky: [string, string, string]; sun: string; far: string; mid: string; near: string; water?: string };263 264function Landscape({ p, kind, label, pano }: { p: Palette; kind: "coast" | "dunes" | "alps" | "fjord" | "terraces" | "city" | "lagoon"; label: string; pano?: boolean }) {265 const id = `fd-zpx-${kind}${useId().replace(/:/g, "")}`;266 const ridge = (y: number, amp: number, seed: number) => {267 let d = `M0 ${y}`;268 for (let x = 0; x <= 400; x += 25) {269 const h = Math.sin(x * 0.021 + seed) * amp + Math.sin(x * 0.053 + seed * 2.1) * amp * 0.45;270 d += ` L${x} ${(y - Math.abs(h)).toFixed(1)}`;271 }272 return d + " L400 300 L0 300 Z";273 };274 return (275 <svg viewBox="0 0 400 300" preserveAspectRatio={pano ? "xMidYMax slice" : "xMidYMid slice"} role="img" aria-label={label}>276 <defs>277 <linearGradient id={`${id}-s`} x1="0" y1="0" x2="0" y2="1">278 <stop offset="0" stopColor={p.sky[0]} />279 <stop offset=".6" stopColor={p.sky[1]} />280 <stop offset="1" stopColor={p.sky[2]} />281 </linearGradient>282 <radialGradient id={`${id}-g`}>283 <stop offset="0" stopColor={p.sun} stopOpacity=".7" />284 <stop offset="1" stopColor={p.sun} stopOpacity="0" />285 </radialGradient>286 <linearGradient id={`${id}-w`} x1="0" y1="0" x2="0" y2="1">287 <stop offset="0" stopColor={p.water ?? p.sky[2]} />288 <stop offset="1" stopColor={p.near} />289 </linearGradient>290 </defs>291 <rect width="400" height="300" fill={`url(#${id}-s)`} />292 <circle cx={kind === "city" ? 300 : 270} cy={kind === "alps" ? 90 : 130} r="90" fill={`url(#${id}-g)`} />293 <circle cx={kind === "city" ? 300 : 270} cy={kind === "alps" ? 90 : 130} r="24" fill={p.sun} />294 {kind === "alps" && (295 <>296 <path d="M0 210 L70 110 L110 150 L170 70 L240 160 L290 100 L400 200 L400 300 L0 300 Z" fill={p.far} />297 <path d="M150 92 L170 70 L190 96 L178 92 L170 100 L160 92 Z M278 114 L290 100 L304 118 Z" fill="#fff" opacity=".9" />298 <path d={ridge(240, 22, 2)} fill={p.mid} />299 <path d={ridge(275, 12, 5)} fill={p.near} />300 </>301 )}302 {kind === "dunes" && (303 <>304 <path d="M0 200 C 80 170 140 190 200 180 S 330 150 400 175 L400 300 L0 300 Z" fill={p.far} />305 <path d="M0 240 C 90 200 160 230 250 205 S 360 220 400 210 L400 300 L0 300 Z" fill={p.mid} />306 <path d="M0 270 C 120 250 200 270 300 250 S 380 262 400 258 L400 300 L0 300 Z" fill={p.near} />307 </>308 )}309 {(kind === "coast" || kind === "lagoon" || kind === "fjord") && (310 <>311 {kind === "fjord" && <path d="M0 120 L60 90 L120 170 L0 210 Z M400 110 L330 80 L270 170 L400 220 Z" fill={p.far} />}312 {kind === "coast" && <path d="M220 175 C 260 140 300 150 340 120 L400 110 L400 190 Z" fill={p.far} />}313 <rect x="0" y="180" width="400" height="120" fill={`url(#${id}-w)`} />314 {[0, 1, 2, 3, 4, 5].map((i) => (315 <path key={i} d={`M${20 + i * 30} ${195 + i * 14} h${60 + i * 16}`} stroke="#fff" strokeOpacity={0.35 - i * 0.04} strokeWidth="1.5" strokeLinecap="round" />316 ))}317 {kind === "coast" && <path d="M0 250 C 80 230 140 260 220 250 L220 300 L0 300 Z" fill={p.near} />}318 {kind === "lagoon" && (319 <>320 <path d="M60 230 C 90 222 130 224 150 232 L 60 232 Z" fill={p.near} />321 <path d="M120 232 L 122 190 M122 190 C 110 186 104 192 100 198 M122 190 C 134 184 142 188 146 196 M122 190 C 120 178 126 172 132 170" stroke={p.near} strokeWidth="3" fill="none" strokeLinecap="round" />322 </>323 )}324 </>325 )}326 {kind === "terraces" && (327 <>328 {[0, 1, 2, 3, 4, 5, 6].map((i) => (329 <path key={i} d={`M0 ${140 + i * 24} C 100 ${120 + i * 24} 260 ${160 + i * 24} 400 ${130 + i * 24} L400 300 L0 300 Z`} fill={i % 2 ? p.mid : p.far} opacity={0.6 + i * 0.06} />330 ))}331 <path d="M0 290 C 120 270 260 290 400 275 L400 300 L0 300 Z" fill={p.near} />332 </>333 )}334 {kind === "city" && (335 <>336 {Array.from({ length: 14 }, (_, i) => {337 const h = 50 + ((i * 53) % 90);338 return <rect key={i} x={i * 30} y={230 - h} width="26" height={h + 70} fill={i % 3 ? p.far : p.mid} />;339 })}340 {Array.from({ length: 40 }, (_, i) => (341 <rect key={i} x={(i * 31) % 400 + 6} y={160 + ((i * 17) % 60)} width="3" height="4" fill={p.sun} opacity=".75" />342 ))}343 <rect x="0" y="240" width="400" height="60" fill={p.near} />344 </>345 )}346 </svg>347 );348}349 350const TILES: { kind: Parameters<typeof Landscape>[0]["kind"]; label: string; p: Palette; place: string }[] = [351 { kind: "coast", label: "Cliffs above the sea at golden hour", place: "Ponta da Piedade", p: { sky: ["#F6C99A", "#F2A07B", "#E98C77"], sun: "#FFF1D6", far: "#B4614A", mid: "#8C4636", near: "#5B2E26", water: "#3E7E9A" } },352 { kind: "alps", label: "Snow peaks at dawn", place: "Dolomites", p: { sky: ["#BFD3EF", "#E9D7E6", "#F7E3D4"], sun: "#FFF6E8", far: "#7C8AA6", mid: "#4F5D78", near: "#2E3850" } },353 { kind: "lagoon", label: "Lagoon with a palm tree", place: "Aitutaki", p: { sky: ["#BFEFF2", "#E3F7F2", "#F8F3E4"], sun: "#FFFFFF", far: "#7FD3CF", mid: "#3FB3B2", near: "#2B6B5E", water: "#4FC7C7" } },354 { kind: "fjord", label: "Fjord at blue hour", place: "Lofoten", p: { sky: ["#1F2E4F", "#40517A", "#7A87AA"], sun: "#E8ECF8", far: "#1A2238", mid: "#2A3654", near: "#0E1426", water: "#2E3E63" } },355 { kind: "terraces", label: "Green rice terraces", place: "Banaue", p: { sky: ["#DDEBD2", "#C9E0B8", "#B7D49E"], sun: "#FDFDF0", far: "#6E9E4C", mid: "#4D7E33", near: "#2E5520" } },356 { kind: "city", label: "City skyline at dusk", place: "Lisbon", p: { sky: ["#2A2350", "#7A4E86", "#E58F7B"], sun: "#FFD39A", far: "#2B2440", mid: "#3B3058", near: "#1A1530" } },357 { kind: "dunes", label: "Desert dunes", place: "Wadi Rum", p: { sky: ["#F7D8A8", "#F3B87A", "#EE9C62"], sun: "#FFF2D0", far: "#E39A5C", mid: "#C9723C", near: "#9E4F25" } },358];359 360export function Demo() {361 return (362 <div className="fd-zpx-demo">363 <style>{demoCss}</style>364 <ZoomParallax365 tiles={TILES.map((t, i) => (366 <figure key={t.kind} className="fd-zpx-demo-fig">367 <Landscape p={t.p} kind={t.kind} label={t.label} pano={i === 6} />368 <figcaption className="fd-zpx-demo-place">{t.place}</figcaption>369 </figure>370 ))}371 overlay={372 <div className="fd-zpx-demo-ov">373 <span className="fd-zpx-demo-brand">Wayfarer & Co.</span>374 <h1 className="fd-zpx-demo-h">375 Slow travel,376 <br />377 far from the crowds.378 </h1>379 <p className="fd-zpx-demo-cue" aria-hidden="true">380 <span className="fd-zpx-demo-cue-line" />381 Scroll382 </p>383 </div>384 }385 caption={386 <p className="fd-zpx-demo-cap">387 <strong>212 small-group journeys</strong> · 41 countries · guides who live there388 </p>389 }390 />391 <section className="fd-zpx-demo-after">392 <h2 className="fd-zpx-demo-h2">Autumn departures open 3 November.</h2>393 </section>394 </div>395 );396}397 398const demoCss = `399.fd-zpx-demo{display:block;margin:0;padding:0;background:#F5F2EC;color:#1A1714;min-height:100vh}400.fd-zpx-demo,.fd-zpx-demo *{box-sizing:border-box}401.fd-zpx-demo-fig{position:relative;display:block;width:100%;height:100%;margin:0;padding:0}402.fd-zpx-demo-fig svg{display:block;width:100%;height:100%}403.fd-zpx-demo-place{position:absolute;left:12px;bottom:10px;margin:0;padding:5px 9px;border-radius:9999px;background:rgba(20,16,12,.38);color:#fff;404 font:500 0.6875rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.03em;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}405.fd-zpx-overlay::before{content:"";position:absolute;inset:0;z-index:-1;background:linear-gradient(to top,rgba(24,14,10,.55),rgba(24,14,10,0) 55%),linear-gradient(to bottom,rgba(24,14,10,.25),rgba(24,14,10,0) 20%)}406.fd-zpx-demo-ov{display:flex;flex-direction:column;height:100%;color:#fff;text-shadow:0 2px 30px rgba(40,14,8,.35)}407.fd-zpx-demo-brand{display:block;margin:0 0 auto;color:#fff;font:600 1.0625rem/1 ui-sans-serif,-apple-system,"Inter",sans-serif;letter-spacing:-0.01em}408.fd-zpx-demo-h{margin:0;padding:0;color:#fff;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}409.fd-zpx-demo-cue{display:inline-flex;align-items:center;gap:10px;margin:24px 0 0;padding:0;color:rgba(255,255,255,.85);font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}410.fd-zpx-demo-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(255,255,255,.3)}411.fd-zpx-demo-cue-line::after{content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#fff;animation:fd-zpx-cue 1.6s cubic-bezier(0.65,0,0.35,1) infinite}412@keyframes fd-zpx-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}413.fd-zpx-demo-cap{margin:0;padding:0;color:#5E564C;text-align:center;font:400 0.9375rem/1.4 ui-sans-serif,-apple-system,"Inter",sans-serif}414.fd-zpx-demo-cap strong{color:#1A1714;font-weight:600}415.fd-zpx-demo-after{max-width:1200px;margin:0 auto;padding:96px clamp(16px,4vw,48px) 140px;text-align:center}416.fd-zpx-demo-h2{margin:0;padding:0;color:#1A1714;font:600 clamp(1.75rem,4vw,3rem)/1.05 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.035em}417@media (prefers-reduced-motion: reduce){.fd-zpx-demo-cue-line::after{animation:none}}418`;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.