Hover Transitions
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Browser support · All evergreen browsers (3D transforms, position: sticky). Reduced motion shows the three services statically.
npx shadcn@latest add https://freedesignmd.com/r/scroll-split-card.json1/**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 nearest12 * 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 front19 * artwork is aria-hidden. prefers-reduced-motion shows the revealed services20 * 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 form51const 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 panels151 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 position204 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 <section250 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 <div276 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 Halden523 </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 what529 <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 <ScrollSplitCard541 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.studio561 </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`;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.
A manifesto whose words light up as you scroll, with keywords that get a hand-drawn marker underline.