Scroll Split Card

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

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

Browser support · All evergreen browsers (3D transforms, position: sticky). Reduced motion shows the three services statically.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/scroll-split-card.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/scroll-split-card.tsx
1/**
2 * ScrollSplitCard — a pinned scroll scene. One card splits into three panels,
3 * fans out in 3D and flips one by one to reveal three services on the back.
4 * Progress is scrubbed by scroll and lightly smoothed so wheel steps glide.
5 *
6 * Props:
7 * - front: ReactNode — artwork for the whole card; each panel shows its third.
8 * - services: three { kicker, title, body, points, meta, accent }.
9 * - intro / outro: headings above the card; they crossfade mid-scene.
10 * - length: scroll length in viewport heights (default 3.2).
11 * - scrollContainer: ref to a scrolling element. If omitted, the nearest
12 * scrollable ancestor is detected, falling back to the window.
13 * - cue: label of the scroll hint (default "Scroll"); pass null to hide it.
14 * CSS vars: --fd-ssc-bg, --fd-ssc-back, --fd-ssc-ink, --fd-ssc-radius.
15 *
16 * Layout: three columns on wide stages, three rows under 640px (flip on X).
17 * Browser support: all evergreen browsers (3D transforms, position: sticky).
18 * Accessibility: service copy is real text in the DOM at all times; the front
19 * artwork is aria-hidden. prefers-reduced-motion shows the revealed services
20 * without pinning or flipping.
21 */
22import { useEffect, useId, useRef } from "react";
23import type { CSSProperties, ReactNode, RefObject } from "react";
24
25export type SplitService = {
26 kicker: string;
27 title: string;
28 body: string;
29 points: string[];
30 meta: string;
31 accent: string;
32 /** Optional big display mark on the back, e.g. "0→1". */
33 glyph?: string;
34};
35
36export interface ScrollSplitCardProps {
37 front: ReactNode;
38 services: [SplitService, SplitService, SplitService];
39 intro?: ReactNode;
40 outro?: ReactNode;
41 length?: number;
42 scrollContainer?: RefObject<HTMLElement | null>;
43 cue?: string | null;
44 className?: string;
45 style?: CSSProperties;
46}
47
48const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
49const seg = (p: number, a: number, b: number) => clamp((p - a) / (b - a));
50// cubic-bezier(0.65, 0, 0.35, 1)-like in/out, cheap closed form
51const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
52
53function findScrollParent(el: HTMLElement): HTMLElement | null {
54 let p = el.parentElement;
55 while (p && p !== document.body && p !== document.documentElement) {
56 const oy = getComputedStyle(p).overflowY;
57 if ((oy === "auto" || oy === "scroll" || oy === "overlay") && p.scrollHeight > p.clientHeight + 1) return p;
58 p = p.parentElement;
59 }
60 return null;
61}
62
63export function ScrollSplitCard({
64 front,
65 services,
66 intro,
67 outro,
68 length = 3.2,
69 scrollContainer,
70 cue = "Scroll",
71 className,
72 style,
73}: ScrollSplitCardProps) {
74 const root = useRef<HTMLElement>(null);
75 const stage = useRef<HTMLDivElement>(null);
76 const card = useRef<HTMLDivElement>(null);
77 const whole = useRef<HTMLDivElement>(null);
78 const panels = useRef<(HTMLDivElement | null)[]>([]);
79 const introRef = useRef<HTMLDivElement>(null);
80 const outroRef = useRef<HTMLDivElement>(null);
81 const cueRef = useRef<HTMLDivElement>(null);
82 const fill = useRef<HTMLSpanElement>(null);
83
84 useEffect(() => {
85 const el = root.current;
86 const st = stage.current;
87 const cd = card.current;
88 if (!el || !st || !cd) return;
89
90 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
91 if (reduced) {
92 el.dataset.reduced = "";
93 const setOrient = () => (el.dataset.orient = st.clientWidth < 640 ? "rows" : "cols");
94 setOrient();
95 const ro = new ResizeObserver(setOrient);
96 ro.observe(el);
97 return () => ro.disconnect();
98 }
99
100 const sc = scrollContainer?.current ?? findScrollParent(el);
101 const target: HTMLElement | Window = sc ?? window;
102 if (sc) el.dataset.contained = "";
103
104 let rows = false;
105 let gap = 24;
106 let R = 24;
107 let vh = 0;
108 let shown = -1;
109 let raf = 0;
110 let visible = true;
111 let lastT = 0;
112 let lastRi = -1;
113
114 const measure = () => {
115 vh = sc ? sc.clientHeight : window.innerHeight;
116 if (sc) el.style.setProperty("--fd-ssc-vh", `${vh}px`);
117 const w = st.clientWidth;
118 const h = st.clientHeight || vh;
119 rows = w < 640;
120 el.dataset.orient = rows ? "rows" : "cols";
121 let W: number;
122 let H: number;
123 if (rows) {
124 gap = 12;
125 R = 20;
126 W = Math.min(w - 32, 520);
127 H = Math.min(h * 0.6, 560);
128 } else {
129 gap = Math.round(Math.min(32, w * 0.022));
130 R = 26;
131 W = Math.min(1000, w - 96 - gap * 2);
132 H = Math.min(h * 0.58, W * 0.6);
133 }
134 cd.style.width = `${Math.round(W)}px`;
135 cd.style.height = `${Math.round(H)}px`;
136 el.style.setProperty("--fd-ssc-r", `${R}px`);
137 lastRi = -1;
138 };
139
140 const progress = () => {
141 const r = el.getBoundingClientRect();
142 const top = sc ? r.top - sc.getBoundingClientRect().top : r.top;
143 const range = r.height - vh;
144 return range > 0 ? clamp(-top / range) : 0;
145 };
146
147 const render = (p: number) => {
148 const sep = easeInOut(seg(p, 0.05, 0.32));
149 const fan = Math.sin(Math.PI * seg(p, 0.05, 0.46));
150 // one whole card while closed: hides sub-pixel seams between panels
151 const split = p > 0.051;
152 if (whole.current) whole.current.style.opacity = split ? "0" : "1";
153 const ri = Math.round(R * sep * 10) / 10;
154 if (ri !== lastRi) {
155 cd.style.setProperty("--fd-ssc-ri", `${ri}px`);
156 lastRi = ri;
157 }
158 const cardScale = 0.94 + 0.06 * easeInOut(seg(p, 0, 0.3));
159 cd.style.transform = `scale(${cardScale.toFixed(4)})`;
160
161 for (let i = 0; i < 3; i++) {
162 const pn = panels.current[i];
163 if (!pn) continue;
164 const d = i - 1;
165 const f = easeInOut(seg(p, 0.34 + i * 0.075, 0.62 + i * 0.075));
166 const lift = Math.sin(Math.PI * f);
167 let t: string;
168 if (rows) {
169 const y = d * gap * sep;
170 const x = fan * (d === 0 ? -10 : 14 * d);
171 const rz = -d * 2.4 * fan;
172 const rx = -d * 16 * fan;
173 t = `translate3d(${x.toFixed(2)}px,${y.toFixed(2)}px,${(lift * 70 + fan * 20).toFixed(2)}px) rotateZ(${rz.toFixed(3)}deg) rotateX(${(rx - 180 * f).toFixed(3)}deg)`;
174 } else {
175 const x = d * gap * sep;
176 const y = fan * (d === 0 ? -14 : 18) - lift * 14;
177 const rz = d * 3.2 * fan + d * 1.6 * lift;
178 const ry = -d * 18 * fan;
179 t = `translate3d(${x.toFixed(2)}px,${y.toFixed(2)}px,${(lift * 90 + fan * 30).toFixed(2)}px) rotateZ(${rz.toFixed(3)}deg) rotateY(${(ry + 180 * f).toFixed(3)}deg)`;
180 }
181 pn.style.transform = t;
182 pn.style.zIndex = String(lift > 0.05 ? 3 : d === 0 ? 2 : 1);
183 }
184
185 const swap = easeInOut(seg(p, 0.36, 0.56));
186 if (introRef.current) {
187 introRef.current.style.opacity = (1 - swap).toFixed(3);
188 introRef.current.style.transform = `translate3d(0,${(-18 * swap).toFixed(2)}px,0)`;
189 }
190 if (outroRef.current) {
191 outroRef.current.style.opacity = swap.toFixed(3);
192 outroRef.current.style.transform = `translate3d(0,${(18 * (1 - swap)).toFixed(2)}px,0)`;
193 }
194 if (cueRef.current) cueRef.current.style.opacity = (1 - seg(p, 0, 0.06)).toFixed(3);
195 if (fill.current) fill.current.style.transform = `scaleX(${p.toFixed(4)})`;
196 };
197
198 const tick = (t: number) => {
199 const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016);
200 lastT = t;
201 const goal = progress();
202 if (shown < 0) shown = goal;
203 // critically damped glide toward the scroll position
204 shown += (goal - shown) * (1 - Math.exp(-dt * 14));
205 if (Math.abs(goal - shown) < 0.0004) shown = goal;
206 render(shown);
207 raf = shown !== goal ? requestAnimationFrame(tick) : 0;
208 };
209 const kick = () => {
210 if (!raf && visible && !document.hidden) {
211 lastT = performance.now();
212 raf = requestAnimationFrame(tick);
213 }
214 };
215 const onResize = () => {
216 measure();
217 render(shown < 0 ? progress() : shown);
218 kick();
219 };
220
221 measure();
222 shown = progress();
223 render(shown);
224
225 const io = new IntersectionObserver(
226 ([e]) => {
227 visible = e.isIntersecting;
228 if (visible) kick();
229 },
230 { root: sc ?? null, rootMargin: "200px 0px" },
231 );
232 io.observe(el);
233 const ro = new ResizeObserver(onResize);
234 ro.observe(st);
235 target.addEventListener("scroll", kick, { passive: true });
236 window.addEventListener("resize", onResize);
237 document.addEventListener("visibilitychange", kick);
238 return () => {
239 io.disconnect();
240 ro.disconnect();
241 target.removeEventListener("scroll", kick);
242 window.removeEventListener("resize", onResize);
243 document.removeEventListener("visibilitychange", kick);
244 if (raf) cancelAnimationFrame(raf);
245 };
246 }, [scrollContainer]);
247
248 return (
249 <section
250 ref={root}
251 className={`fd-ssc${className ? " " + className : ""}`}
252 data-orient="cols"
253 style={{ ["--fd-ssc-len" as string]: String(length), ...style }}
254 >
255 <style>{css}</style>
256 <div ref={stage} className="fd-ssc-stage">
257 <div className="fd-ssc-heads">
258 {intro && (
259 <div ref={introRef} className="fd-ssc-head fd-ssc-intro">
260 {intro}
261 </div>
262 )}
263 {outro && (
264 <div ref={outroRef} className="fd-ssc-head fd-ssc-outro">
265 {outro}
266 </div>
267 )}
268 </div>
269 <div className="fd-ssc-scene">
270 <div ref={card} className="fd-ssc-card">
271 <div ref={whole} className="fd-ssc-whole" aria-hidden="true">
272 <div className="fd-ssc-art fd-ssc-art-whole">{front}</div>
273 </div>
274 {services.map((s, i) => (
275 <div
276 key={i}
277 ref={(n) => {
278 panels.current[i] = n;
279 }}
280 className="fd-ssc-panel"
281 data-i={i}
282 >
283 <div className="fd-ssc-face fd-ssc-front" aria-hidden="true">
284 <div className="fd-ssc-art">{front}</div>
285 </div>
286 <article className="fd-ssc-face fd-ssc-back" style={{ ["--fd-ssc-accent" as string]: s.accent }}>
287 <div className="fd-ssc-back-top">
288 <span className="fd-ssc-num">{String(i + 1).padStart(2, "0")}</span>
289 <span className="fd-ssc-kicker">{s.kicker}</span>
290 </div>
291 {s.glyph && (
292 <span className="fd-ssc-glyph" aria-hidden="true">
293 {s.glyph}
294 </span>
295 )}
296 <h3 className="fd-ssc-title">{s.title}</h3>
297 <p className="fd-ssc-body">{s.body}</p>
298 <ul className="fd-ssc-points">
299 {s.points.map((pt) => (
300 <li key={pt} className="fd-ssc-point">
301 {pt}
302 </li>
303 ))}
304 </ul>
305 <div className="fd-ssc-meta">{s.meta}</div>
306 </article>
307 </div>
308 ))}
309 </div>
310 </div>
311 <div className="fd-ssc-foot" aria-hidden="true">
312 {cue && (
313 <div ref={cueRef} className="fd-ssc-cue">
314 <span className="fd-ssc-cue-line" />
315 {cue}
316 </div>
317 )}
318 <span className="fd-ssc-rail">
319 <span ref={fill} className="fd-ssc-fill" />
320 </span>
321 </div>
322 </div>
323 </section>
324 );
325}
326
327const css = `
328.fd-ssc{
329 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-ease-in-out:cubic-bezier(0.65,0,0.35,1);
330 --fd-spring:cubic-bezier(0.34,1.56,0.64,1);
331 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
332 --fd-ssc-bg:#0B0B0F;--fd-ssc-back:#F4F1EA;--fd-ssc-ink:#15140F;--fd-ssc-muted:#6B6860;
333 --fd-ssc-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
334 --fd-ssc-display:"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter Display","Inter","Segoe UI",Roboto,sans-serif;
335 --fd-ssc-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
336 --fd-ssc-r:26px;--fd-ssc-ri:0px;
337 position:relative;display:block;margin:0;padding:0;
338 height:calc(var(--fd-ssc-len) * 100vh);height:calc(var(--fd-ssc-len) * 100svh);
339 background:var(--fd-ssc-bg);color:#F5F5F7;
340}
341.fd-ssc,.fd-ssc *,.fd-ssc *::before,.fd-ssc *::after{box-sizing:border-box}
342.fd-ssc[data-contained]{height:calc(var(--fd-ssc-len) * var(--fd-ssc-vh,100vh))}
343.fd-ssc-stage{
344 position:sticky;top:0;height:100vh;height:100svh;width:100%;overflow:hidden;
345 display:grid;grid-template-rows:auto 1fr auto;margin:0;padding:clamp(28px,6vh,64px) 0 28px;
346}
347.fd-ssc[data-contained] .fd-ssc-stage{height:var(--fd-ssc-vh,100vh)}
348.fd-ssc-heads{position:relative;display:grid;margin:0;padding:0 24px}
349.fd-ssc-head{grid-area:1/1;margin:0;padding:0;text-align:center;will-change:transform,opacity}
350.fd-ssc-outro{opacity:0}
351.fd-ssc-scene{
352 position:relative;display:flex;align-items:center;justify-content:center;
353 perspective:1700px;perspective-origin:50% 45%;margin:0;padding:0;min-height:0;
354}
355.fd-ssc-card{position:relative;width:min(1000px,calc(100% - 96px));height:56%;transform-style:preserve-3d;will-change:transform}
356.fd-ssc-whole{
357 position:absolute;inset:0;border-radius:var(--fd-ssc-r);overflow:hidden;z-index:4;pointer-events:none;
358 box-shadow:0 40px 80px -30px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.07) inset;
359}
360.fd-ssc-panel{
361 position:absolute;top:0;left:0;width:33.3334%;height:100%;
362 transform-style:preserve-3d;will-change:transform;
363}
364.fd-ssc-panel[data-i="1"]{left:33.3333%}
365.fd-ssc-panel[data-i="2"]{left:66.6666%}
366.fd-ssc[data-orient="rows"] .fd-ssc-panel{left:0;width:100%;height:33.3334%}
367.fd-ssc[data-orient="rows"] .fd-ssc-panel[data-i="1"]{top:33.3333%}
368.fd-ssc[data-orient="rows"] .fd-ssc-panel[data-i="2"]{top:66.6666%}
369.fd-ssc-face{
370 position:absolute;inset:0;overflow:hidden;margin:0;
371 -webkit-backface-visibility:hidden;backface-visibility:hidden;
372}
373.fd-ssc-front{box-shadow:0 30px 60px -28px rgba(0,0,0,.7)}
374.fd-ssc-panel[data-i="0"] .fd-ssc-front{border-radius:var(--fd-ssc-r) var(--fd-ssc-ri) var(--fd-ssc-ri) var(--fd-ssc-r)}
375.fd-ssc-panel[data-i="1"] .fd-ssc-front{border-radius:var(--fd-ssc-ri)}
376.fd-ssc-panel[data-i="2"] .fd-ssc-front{border-radius:var(--fd-ssc-ri) var(--fd-ssc-r) var(--fd-ssc-r) var(--fd-ssc-ri)}
377.fd-ssc[data-orient="rows"] .fd-ssc-panel[data-i="0"] .fd-ssc-front{border-radius:var(--fd-ssc-r) var(--fd-ssc-r) var(--fd-ssc-ri) var(--fd-ssc-ri)}
378.fd-ssc[data-orient="rows"] .fd-ssc-panel[data-i="2"] .fd-ssc-front{border-radius:var(--fd-ssc-ri) var(--fd-ssc-ri) var(--fd-ssc-r) var(--fd-ssc-r)}
379.fd-ssc-art{position:absolute;top:0;left:0;width:300%;height:100%;margin:0;padding:0}
380.fd-ssc-art-whole{width:100%}
381.fd-ssc-panel[data-i="1"] .fd-ssc-art{left:-100%}
382.fd-ssc-panel[data-i="2"] .fd-ssc-art{left:-200%}
383.fd-ssc[data-orient="rows"] .fd-ssc-panel .fd-ssc-art{left:0;width:100%;height:300%}
384.fd-ssc[data-orient="rows"] .fd-ssc-panel[data-i="1"] .fd-ssc-art{top:-100%}
385.fd-ssc[data-orient="rows"] .fd-ssc-panel[data-i="2"] .fd-ssc-art{top:-200%}
386.fd-ssc[data-orient="rows"] .fd-ssc-art-whole{height:100%}
387
388.fd-ssc-back{
389 transform:rotateY(180deg);border-radius:var(--fd-ssc-r);
390 background:var(--fd-ssc-back);color:var(--fd-ssc-ink);
391 display:flex;flex-direction:column;padding:clamp(20px,2.4vw,32px);
392 box-shadow:0 40px 70px -34px rgba(0,0,0,.75),0 0 0 1px rgba(0,0,0,.04) inset;
393}
394.fd-ssc-back::after{
395 content:"";position:absolute;right:-30%;top:-30%;width:80%;aspect-ratio:1;border-radius:50%;
396 background:radial-gradient(closest-side,color-mix(in srgb,var(--fd-ssc-accent) 26%,transparent),transparent);
397 pointer-events:none;
398}
399.fd-ssc[data-orient="rows"] .fd-ssc-back{transform:rotateX(180deg);padding:16px 18px;justify-content:center}
400.fd-ssc-back-top{display:flex;align-items:center;gap:10px;margin:0;padding:0}
401.fd-ssc-back-top + .fd-ssc-title{margin-top:auto}
402.fd-ssc-num{
403 display:inline-flex;align-items:center;justify-content:center;height:26px;padding:0 10px;border-radius:9999px;
404 background:var(--fd-ssc-ink);color:var(--fd-ssc-back);font:600 0.75rem/1 var(--fd-ssc-mono);letter-spacing:0;
405}
406.fd-ssc-kicker{display:inline-flex;align-items:center;gap:8px;font:500 0.8125rem/1.2 var(--fd-ssc-sans);letter-spacing:0;color:var(--fd-ssc-muted)}
407.fd-ssc-kicker::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--fd-ssc-accent)}
408.fd-ssc-glyph{
409 display:block;margin:clamp(18px,2.4vh,28px) 0 auto;padding:0;color:var(--fd-ssc-accent);
410 font:300 clamp(2.75rem,5vw,4.5rem)/0.9 var(--fd-ssc-display);letter-spacing:-0.04em;
411}
412.fd-ssc-glyph + .fd-ssc-title{margin-top:14px}
413.fd-ssc[data-orient="rows"] .fd-ssc-glyph{display:none}
414.fd-ssc-title{
415 margin:24px 0 10px;padding:0;color:var(--fd-ssc-ink);
416 font:650 clamp(1.5rem,2.3vw,2rem)/1.06 var(--fd-ssc-display);letter-spacing:-0.025em;
417}
418.fd-ssc-body{margin:0;padding:0;color:#45433D;font:400 0.9375rem/1.5 var(--fd-ssc-sans);letter-spacing:0}
419.fd-ssc-points{list-style:none;margin:18px 0 0;padding:0;display:grid;gap:8px}
420.fd-ssc-point{
421 position:relative;margin:0;padding:0 0 0 22px;color:var(--fd-ssc-ink);
422 font:500 0.8125rem/1.35 var(--fd-ssc-sans);letter-spacing:0;
423}
424.fd-ssc-point::before{
425 content:"";position:absolute;left:0;top:.2em;width:12px;height:8px;
426 border-left:1.6px solid var(--fd-ssc-accent);border-bottom:1.6px solid var(--fd-ssc-accent);
427 transform:rotate(-45deg) scale(.8);transform-origin:30% 60%;
428}
429.fd-ssc-meta{
430 margin:20px 0 0;padding:14px 0 0;border-top:1px solid rgba(21,20,15,.12);
431 color:var(--fd-ssc-ink);font:500 0.75rem/1.2 var(--fd-ssc-mono);letter-spacing:0;
432}
433.fd-ssc[data-orient="rows"] .fd-ssc-back-top{margin:0}
434.fd-ssc[data-orient="rows"] .fd-ssc-title{margin:10px 0 4px;font-size:1.25rem}
435.fd-ssc[data-orient="rows"] .fd-ssc-body{font-size:0.8125rem;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
436.fd-ssc[data-orient="rows"] .fd-ssc-points,.fd-ssc[data-orient="rows"] .fd-ssc-meta{display:none}
437.fd-ssc[data-orient="rows"] .fd-ssc-num{height:22px;padding:0 8px}
438
439.fd-ssc-foot{position:relative;display:flex;flex-direction:column;align-items:center;gap:14px;margin:0;padding:0 24px}
440.fd-ssc-cue{
441 display:inline-flex;align-items:center;gap:10px;margin:0;padding:0;
442 color:rgba(245,245,247,.6);font:500 0.75rem/1 var(--fd-ssc-mono);letter-spacing:.08em;text-transform:uppercase;
443}
444.fd-ssc-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(245,245,247,.16)}
445.fd-ssc-cue-line::after{
446 content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#F5F5F7;
447 animation:fd-ssc-cue 1.6s var(--fd-ease-in-out) infinite;
448}
449@keyframes fd-ssc-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}
450.fd-ssc-rail{position:relative;display:block;width:min(220px,40%);height:2px;border-radius:9999px;background:rgba(245,245,247,.12);overflow:hidden}
451.fd-ssc-fill{position:absolute;inset:0;display:block;background:#F5F5F7;transform:scaleX(0);transform-origin:0 50%;will-change:transform}
452
453/* Reduced motion: no pin, panels shown flipped, side by side. */
454.fd-ssc[data-reduced]{height:auto}
455.fd-ssc[data-reduced] .fd-ssc-stage{position:relative;height:auto;min-height:0;padding:56px 0}
456.fd-ssc[data-reduced] .fd-ssc-intro,.fd-ssc[data-reduced] .fd-ssc-whole,.fd-ssc[data-reduced] .fd-ssc-front,.fd-ssc[data-reduced] .fd-ssc-foot{display:none}
457.fd-ssc[data-reduced] .fd-ssc-outro{opacity:1}
458.fd-ssc[data-reduced] .fd-ssc-scene{perspective:none;padding:32px 24px 0}
459.fd-ssc[data-reduced] .fd-ssc-card{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;width:min(1040px,100%);height:auto;transform:none}
460.fd-ssc[data-reduced][data-orient="rows"] .fd-ssc-card{grid-template-columns:1fr}
461.fd-ssc[data-reduced] .fd-ssc-panel,.fd-ssc[data-reduced][data-orient] .fd-ssc-panel[data-i]{position:relative;inset:auto;left:auto;top:auto;width:auto;height:auto;transform:none}
462.fd-ssc[data-reduced][data-orient] .fd-ssc-body{display:block;-webkit-line-clamp:unset;overflow:visible}
463.fd-ssc[data-reduced] .fd-ssc-back{position:relative;transform:none;min-height:340px}
464.fd-ssc[data-reduced][data-orient="rows"] .fd-ssc-back{min-height:0;padding:22px}
465@media (prefers-reduced-motion: reduce){.fd-ssc-cue-line::after{animation:none}}
466@media (prefers-contrast: more){.fd-ssc-back{box-shadow:none;border:1px solid var(--fd-ssc-ink)}}
467`;
468
469/* ------------------------------------------------------------------ Demo */
470
471const SERVICES: [SplitService, SplitService, SplitService] = [
472 {
473 kicker: "Zero → One",
474 title: "Starting from a blank page",
475 body: "You have conviction and a deadline. We turn it into a product people can hold — strategy, brand and a shippable v1.",
476 points: ["Positioning & naming sprint", "Clickable prototype in 10 days", "Production v1 with analytics"],
477 meta: "8–12 weeks · from €64k",
478 accent: "#FF7A2F",
479 glyph: "0→1",
480 },
481 {
482 kicker: "One → N",
483 title: "Scaling what already works",
484 body: "Traction is real; the product is creaking. We embed with your team to rebuild the core without stopping the train.",
485 points: ["Design system & component library", "Growth loops and onboarding", "Platform & performance work"],
486 meta: "Quarterly retainer · 4–6 people",
487 accent: "#4C6FFF",
488 glyph: "1→N",
489 },
490 {
491 kicker: "Rapid response",
492 title: "When it has to ship by Friday",
493 body: "A launch slipped or a feature is on fire. A senior strike team scopes in a day and delivers in a week.",
494 points: ["24-hour scoping call", "Senior-only, no handovers", "Fixed price, fixed date"],
495 meta: "1–3 weeks · fixed fee",
496 accent: "#1FA67A",
497 glyph: "72h",
498 },
499];
500
501function HaldenArt() {
502 const gid = `fd-ssc-ring-${useId().replace(/:/g, "")}`;
503 return (
504 <div className="fd-ssc-demo-art">
505 <svg className="fd-ssc-demo-rings" viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
506 <defs>
507 <linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
508 <stop offset="0" stopColor="#fff" stopOpacity=".42" />
509 <stop offset="1" stopColor="#fff" stopOpacity="0" />
510 </linearGradient>
511 </defs>
512 <circle cx="720" cy="250" r="230" fill="none" stroke={`url(#${gid})`} strokeWidth="1" />
513 <circle cx="720" cy="250" r="160" fill="none" stroke={`url(#${gid})`} strokeWidth="1" />
514 <circle cx="720" cy="250" r="90" fill="none" stroke={`url(#${gid})`} strokeWidth="1" />
515 <path d="M0 470 C 250 420 420 520 640 450 S 900 380 1000 410" fill="none" stroke="rgba(255,255,255,.18)" strokeWidth="1" />
516 </svg>
517 <div className="fd-ssc-demo-top">
518 <span className="fd-ssc-demo-mark">
519 <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
520 <path d="M4 4h4v6h8V4h4v16h-4v-6H8v6H4z" fill="currentColor" />
521 </svg>
522 Halden
523 </span>
524 <span className="fd-ssc-demo-small">Product studio · Copenhagen & Berlin</span>
525 <span className="fd-ssc-demo-small fd-ssc-demo-right">Est. 2014 — 140 products shipped</span>
526 </div>
527 <div className="fd-ssc-demo-big">
528 We build what
529 <br />
530 comes next.
531 </div>
532 </div>
533 );
534}
535
536export function Demo() {
537 return (
538 <div className="fd-ssc-demo">
539 <style>{demoCss}</style>
540 <ScrollSplitCard
541 front={<HaldenArt />}
542 services={SERVICES}
543 intro={
544 <>
545 <p className="fd-ssc-demo-eyebrow">Services</p>
546 <h2 className="fd-ssc-demo-h">One studio. Three ways in.</h2>
547 </>
548 }
549 outro={
550 <>
551 <p className="fd-ssc-demo-eyebrow">Pick your starting point</p>
552 <h2 className="fd-ssc-demo-h">Wherever you are, we meet you there.</h2>
553 </>
554 }
555 />
556 <footer className="fd-ssc-demo-after">
557 <p className="fd-ssc-demo-after-k">Not sure which one fits?</p>
558 <p className="fd-ssc-demo-after-t">Book a 30-minute call with a partner — no deck required.</p>
559 <a className="fd-ssc-demo-btn" href="#contact">
560 hello@halden.studio
561 </a>
562 </footer>
563 </div>
564 );
565}
566
567const demoCss = `
568.fd-ssc-demo{display:block;margin:0;padding:0;background:#0B0B0F;color:#F5F5F7;min-height:100vh}
569.fd-ssc-demo,.fd-ssc-demo *{box-sizing:border-box}
570.fd-ssc-demo-eyebrow{margin:0 0 10px;padding:0;color:rgba(245,245,247,.56);font:500 0.8125rem/1.2 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-transform:uppercase}
571.fd-ssc-demo-h{margin:0;padding:0;color:#F5F5F7;font:650 clamp(1.75rem,3.6vw,2.75rem)/1.04 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter","Segoe UI",sans-serif;letter-spacing:-0.03em}
572.fd-ssc-demo-art{
573 position:absolute;inset:0;overflow:hidden;color:#F5F5F7;
574 background:
575 radial-gradient(60% 70% at 78% 22%,rgba(255,122,47,.95) 0%,rgba(255,122,47,0) 60%),
576 radial-gradient(55% 80% at 12% 105%,rgba(76,111,255,.95) 0%,rgba(76,111,255,0) 62%),
577 radial-gradient(40% 50% at 50% 60%,rgba(47,191,143,.35) 0%,rgba(47,191,143,0) 70%),
578 linear-gradient(160deg,#1A1A22 0%,#0E0E13 100%);
579}
580.fd-ssc-demo-art::before{
581 content:"";position:absolute;inset:0;opacity:.5;
582 background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px);
583 background-size:48px 48px;
584 -webkit-mask-image:radial-gradient(80% 80% at 50% 40%,#000,transparent);mask-image:radial-gradient(80% 80% at 50% 40%,#000,transparent);
585}
586.fd-ssc-demo-rings{position:absolute;inset:0;display:block;width:100%;height:100%}
587.fd-ssc-demo-top{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:20px;padding:28px 32px;flex-wrap:wrap}
588.fd-ssc-demo-mark{display:inline-flex;align-items:center;gap:10px;font:650 1.25rem/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.02em}
589.fd-ssc-demo-small{font:500 0.8125rem/1.2 ui-sans-serif,-apple-system,"Inter",sans-serif;color:rgba(245,245,247,.72);letter-spacing:0}
590.fd-ssc-demo-right{margin-left:auto}
591.fd-ssc-demo-big{
592 position:absolute;left:32px;right:32px;bottom:26px;margin:0;
593 font:700 clamp(3rem,8.6vw,7.5rem)/0.9 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.055em;
594}
595.fd-ssc[data-orient="rows"] .fd-ssc-demo-top{padding:20px;gap:6px 14px}
596.fd-ssc[data-orient="rows"] .fd-ssc-demo-right{margin-left:0}
597.fd-ssc[data-orient="rows"] .fd-ssc-demo-big{left:20px;right:20px;bottom:20px;font-size:3.25rem}
598.fd-ssc-demo-after{display:flex;flex-direction:column;align-items:center;gap:12px;margin:0;padding:96px 24px 120px;text-align:center}
599.fd-ssc-demo-after-k{margin:0;padding:0;color:rgba(245,245,247,.56);font:500 0.9375rem/1.4 ui-sans-serif,-apple-system,"Inter",sans-serif}
600.fd-ssc-demo-after-t{margin:0;padding:0;max-width:560px;color:#F5F5F7;font:600 clamp(1.5rem,3vw,2rem)/1.15 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.025em}
601.fd-ssc-demo-btn{
602 display:inline-flex;align-items:center;height:48px;margin:12px 0 0;padding:0 22px;border-radius:9999px;
603 background:#F5F5F7;color:#0B0B0F;text-decoration:none;font:600 0.9375rem/1 ui-sans-serif,-apple-system,"Inter",sans-serif;
604 transition:transform 120ms cubic-bezier(0.22,1,0.36,1);
605}
606.fd-ssc-demo-btn:active{transform:scale(.96);transition:transform 240ms cubic-bezier(0.34,1.56,0.64,1)}
607.fd-ssc-demo-btn:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
608`;

More Scroll & Hover Effects. Same file contract.

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.

Text Reveal on Scroll

A manifesto whose words light up as you scroll, with keywords that get a hand-drawn marker underline.