Sticky Storytelling

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

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

Browser support · All evergreen browsers (position: sticky). Demo media uses color-mix().

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/sticky-storytelling.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/sticky-storytelling.tsx
1/**
2 * StickyStory — Apple-style scrollytelling. The media column stays pinned
3 * while text steps scroll past; the active step drives a crossfade/morph
4 * between media states and a progress rail.
5 *
6 * Props:
7 * - steps: { eyebrow, title, body, stat? }[] — text column.
8 * - renderMedia(active): ReactNode — return every media state; mark the one
9 * matching `active` (e.g. data-active) and crossfade with CSS. The media
10 * root also receives `--fd-sst-p` (0–1 across the story) and `--fd-sst-k`
11 * (fractional step index) every frame for continuous, scrubbed motion.
12 * - header: optional ReactNode rendered above the grid.
13 * - scrollContainer: optional ref to a scrolling element; otherwise the
14 * nearest scrollable ancestor or the window.
15 * CSS vars: --fd-sst-accent, --fd-sst-bg, --fd-sst-ink.
16 *
17 * Browser support: evergreen (position: sticky). Under 760px the media pins
18 * to the top half and steps scroll beneath it.
19 * Accessibility: all copy is real, in-order text. The active step is mirrored
20 * with aria-current. Reduced motion keeps crossfades at 120ms and removes
21 * scale and drift.
22 */
23import { useEffect, useRef, useState } from "react";
24import type { CSSProperties, ReactNode, RefObject } from "react";
25
26export type StoryStep = { eyebrow: string; title: string; body: string; stat?: { value: string; label: string } };
27
28export interface StickyStoryProps {
29 steps: StoryStep[];
30 renderMedia: (active: number) => ReactNode;
31 scrollContainer?: RefObject<HTMLElement | null>;
32 header?: ReactNode;
33 className?: string;
34 style?: CSSProperties;
35}
36
37const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
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 StickyStory({ steps, renderMedia, scrollContainer, header, className, style }: StickyStoryProps) {
50 const root = useRef<HTMLElement>(null);
51 const media = useRef<HTMLDivElement>(null);
52 const stepEls = useRef<(HTMLElement | null)[]>([]);
53 const fill = useRef<HTMLSpanElement>(null);
54 const [active, setActive] = useState(0);
55
56 useEffect(() => {
57 const el = root.current;
58 const md = media.current;
59 if (!el || !md) return;
60 const sc = scrollContainer?.current ?? findScrollParent(el);
61 const target: HTMLElement | Window = sc ?? window;
62 let raf = 0;
63 let cur = -1;
64 let visible = true;
65
66 const frame = () => {
67 raf = 0;
68 // read phase
69 const vh = sc ? sc.clientHeight : window.innerHeight;
70 const top0 = sc ? sc.getBoundingClientRect().top : 0;
71 const line = top0 + vh * 0.55;
72 const rects = stepEls.current.map((s) => (s ? s.getBoundingClientRect() : null));
73 let k = 0;
74 for (let i = 0; i < rects.length; i++) {
75 const r = rects[i];
76 if (!r) continue;
77 const mid = r.top + r.height / 2;
78 if (mid < line) k = i;
79 }
80 // fractional index for continuous morphs
81 const first = rects[0];
82 const last = rects[rects.length - 1];
83 let p = 0;
84 if (first && last) {
85 const a = first.top + first.height / 2;
86 const b = last.top + last.height / 2;
87 p = clamp((line - a) / (b - a || 1));
88 }
89 // write phase
90 md.style.setProperty("--fd-sst-p", p.toFixed(4));
91 md.style.setProperty("--fd-sst-k", (p * (steps.length - 1)).toFixed(4));
92 if (fill.current) fill.current.style.transform = `scaleY(${p.toFixed(4)})`;
93 if (k !== cur) {
94 cur = k;
95 setActive(k);
96 }
97 };
98 const kick = () => {
99 if (!raf && visible) raf = requestAnimationFrame(frame);
100 };
101 const io = new IntersectionObserver(
102 ([e]) => {
103 visible = e.isIntersecting;
104 if (visible) kick();
105 },
106 { root: sc ?? null },
107 );
108 io.observe(el);
109 frame();
110 target.addEventListener("scroll", kick, { passive: true });
111 window.addEventListener("resize", kick);
112 return () => {
113 io.disconnect();
114 target.removeEventListener("scroll", kick);
115 window.removeEventListener("resize", kick);
116 if (raf) cancelAnimationFrame(raf);
117 };
118 }, [scrollContainer, steps.length]);
119
120 return (
121 <section ref={root} className={`fd-sst${className ? " " + className : ""}`} style={style}>
122 <style>{css}</style>
123 {header}
124 <div className="fd-sst-grid">
125 <div className="fd-sst-pin">
126 <div ref={media} className="fd-sst-media" data-active={active}>
127 {renderMedia(active)}
128 </div>
129 <ol className="fd-sst-dots" aria-hidden="true">
130 {steps.map((s, i) => (
131 <li key={s.title} className="fd-sst-dot" data-on={i === active ? "" : undefined}>
132 <span className="fd-sst-dot-n">{String(i + 1).padStart(2, "0")}</span>
133 <span className="fd-sst-dot-l">{s.eyebrow}</span>
134 </li>
135 ))}
136 </ol>
137 </div>
138 <div className="fd-sst-text">
139 <span className="fd-sst-rail" aria-hidden="true">
140 <span ref={fill} className="fd-sst-fill" />
141 </span>
142 {steps.map((s, i) => (
143 <article
144 key={s.title}
145 ref={(n) => {
146 stepEls.current[i] = n;
147 }}
148 className="fd-sst-step"
149 data-on={i === active ? "" : undefined}
150 aria-current={i === active ? "step" : undefined}
151 >
152 <span className="fd-sst-knot" aria-hidden="true" />
153 <p className="fd-sst-eyebrow">
154 <span className="fd-sst-n">{String(i + 1).padStart(2, "0")}</span>
155 {s.eyebrow}
156 </p>
157 <h3 className="fd-sst-title">{s.title}</h3>
158 <p className="fd-sst-body">{s.body}</p>
159 {s.stat && (
160 <p className="fd-sst-stat">
161 <span className="fd-sst-stat-v">{s.stat.value}</span>
162 <span className="fd-sst-stat-l">{s.stat.label}</span>
163 </p>
164 )}
165 </article>
166 ))}
167 </div>
168 </div>
169 </section>
170 );
171}
172
173const css = `
174.fd-sst{
175 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-ease-in-out:cubic-bezier(0.65,0,0.35,1);
176 --fd-spring:cubic-bezier(0.34,1.56,0.64,1);
177 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
178 --fd-sst-accent:#0E9F8E;--fd-sst-bg:#F4F6F5;--fd-sst-ink:#0B1412;--fd-sst-muted:#5D6B67;
179 --fd-sst-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
180 --fd-sst-display:"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter Display","Inter","Segoe UI",Roboto,sans-serif;
181 --fd-sst-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
182 position:relative;display:block;margin:0;padding:0;background:var(--fd-sst-bg);color:var(--fd-sst-ink);
183}
184.fd-sst,.fd-sst *,.fd-sst *::before,.fd-sst *::after{box-sizing:border-box}
185.fd-sst-grid{
186 display:grid;grid-template-columns:minmax(0,1.08fr) minmax(0,.92fr);gap:clamp(32px,6vw,96px);
187 max-width:1240px;margin:0 auto;padding:0 clamp(16px,4vw,56px);
188}
189.fd-sst-pin{
190 position:sticky;top:0;height:100vh;height:100svh;display:flex;flex-direction:column;justify-content:center;gap:20px;margin:0;padding:24px 0;
191}
192.fd-sst-media{
193 --fd-sst-p:0;--fd-sst-k:0;
194 position:relative;width:100%;aspect-ratio:1/1;max-height:72vh;margin:0 auto;border-radius:28px;overflow:hidden;
195 background:#0B1412;box-shadow:0 40px 80px -40px rgba(11,20,18,.45),0 0 0 1px rgba(11,20,18,.06);
196}
197.fd-sst-dots{display:flex;gap:6px;list-style:none;margin:0;padding:0;justify-content:center}
198.fd-sst-dot{
199 display:inline-flex;align-items:center;gap:6px;height:28px;margin:0;padding:0 12px;border-radius:9999px;
200 color:var(--fd-sst-muted);background:transparent;border:1px solid rgba(11,20,18,.1);
201 font:500 0.75rem/1 var(--fd-sst-sans);letter-spacing:0;white-space:nowrap;
202 transition:background var(--fd-dur-2) var(--fd-ease-out),color var(--fd-dur-2) var(--fd-ease-out),border-color var(--fd-dur-2) var(--fd-ease-out);
203}
204.fd-sst-dot-n{font:500 0.6875rem/1 var(--fd-sst-mono)}
205.fd-sst-dot[data-on]{background:var(--fd-sst-ink);color:#fff;border-color:var(--fd-sst-ink)}
206
207.fd-sst-text{position:relative;margin:0;padding:22vh 0 30vh 36px}
208.fd-sst-rail{position:absolute;left:5px;top:22vh;bottom:30vh;width:2px;border-radius:9999px;background:rgba(11,20,18,.08);overflow:hidden}
209.fd-sst-fill{position:absolute;inset:0;display:block;background:var(--fd-sst-accent);transform:scaleY(0);transform-origin:50% 0;will-change:transform}
210.fd-sst-step{
211 position:relative;display:block;margin:0;padding:0;min-height:62vh;
212 opacity:.28;transition:opacity var(--fd-dur-3) var(--fd-ease-out);
213}
214.fd-sst-step:last-child{min-height:40vh}
215.fd-sst-step[data-on]{opacity:1}
216.fd-sst-knot{
217 position:absolute;left:-36px;top:4px;width:12px;height:12px;border-radius:50%;
218 background:var(--fd-sst-bg);border:2px solid rgba(11,20,18,.2);
219 transition:border-color var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring),background var(--fd-dur-2) var(--fd-ease-out);
220}
221.fd-sst-step[data-on] .fd-sst-knot{border-color:var(--fd-sst-accent);background:var(--fd-sst-accent);transform:scale(1.25)}
222.fd-sst-eyebrow{display:flex;align-items:center;gap:10px;margin:0 0 14px;padding:0;color:var(--fd-sst-accent);font:600 0.8125rem/1.2 var(--fd-sst-sans);letter-spacing:0}
223.fd-sst-n{color:var(--fd-sst-muted);font:500 0.75rem/1 var(--fd-sst-mono)}
224.fd-sst-title{margin:0 0 14px;padding:0;color:var(--fd-sst-ink);font:650 clamp(1.75rem,3vw,2.75rem)/1.05 var(--fd-sst-display);letter-spacing:-0.03em}
225.fd-sst-body{margin:0;padding:0;max-width:440px;color:var(--fd-sst-muted);font:400 1.0625rem/1.55 var(--fd-sst-sans);letter-spacing:-0.005em}
226.fd-sst-stat{display:flex;align-items:baseline;gap:12px;margin:28px 0 0;padding:18px 0 0;border-top:1px solid rgba(11,20,18,.1);max-width:440px}
227.fd-sst-stat-v{flex:none;white-space:nowrap;color:var(--fd-sst-ink);font:600 2rem/1 var(--fd-sst-display);letter-spacing:-0.025em}
228.fd-sst-stat-l{color:var(--fd-sst-muted);font:400 0.8125rem/1.35 var(--fd-sst-sans);letter-spacing:0}
229
230@media (max-width:760px){
231 .fd-sst-grid{display:block;padding:0}
232 .fd-sst-pin{height:auto;top:0;z-index:2;padding:12px 16px 12px;gap:10px;background:var(--fd-sst-bg)}
233 .fd-sst-pin::after{content:"";position:absolute;left:0;right:0;top:100%;height:32px;background:linear-gradient(var(--fd-sst-bg),transparent);pointer-events:none}
234 .fd-sst-media{aspect-ratio:16/11;max-height:44vh;border-radius:22px}
235 .fd-sst-dot-l{display:none}
236 .fd-sst-dot{padding:0 10px;height:24px}
237 .fd-sst-text{padding:8vh 16px 30vh 50px}
238 .fd-sst-rail{left:21px;top:8vh}
239 .fd-sst-step{min-height:58vh}
240 .fd-sst-title{font-size:1.625rem}
241 .fd-sst-body{font-size:0.9375rem}
242}
243@media (prefers-reduced-motion: reduce){
244 .fd-sst *{transition-duration:120ms !important}
245}
246`;
247
248/* ------------------------------------------------------------------ Demo */
249
250const STEPS: StoryStep[] = [
251 {
252 eyebrow: "Design",
253 title: "Write the protocol once. In plain language.",
254 body: "Describe the experiment the way you’d explain it to a colleague. Helix compiles it into a validated plate map with controls, replicates and randomised well positions.",
255 stat: { value: "38 min", label: "median time from idea to a runnable 384-well plan" },
256 },
257 {
258 eyebrow: "Run",
259 title: "Robots run it overnight. You sleep.",
260 body: "Plans dispatch to liquid handlers, incubators and readers across your bench. Every pipetting step is logged with volume, tip and timestamp — no more lab-notebook archaeology.",
261 stat: { value: "11,204", label: "plates executed last month across 62 partner labs" },
262 },
263 {
264 eyebrow: "Analyse",
265 title: "Results arrive already curve-fitted.",
266 body: "Dose–response fits, Z′ factors and outlier flags compute as the reader streams. Bad wells are caught at 3 a.m., not at Monday stand-up.",
267 stat: { value: "Z′ 0.81", label: "average assay quality on the platform this quarter" },
268 },
269 {
270 eyebrow: "Share",
271 title: "Every result is reproducible by default.",
272 body: "Publish the protocol, raw reads and analysis as one signed bundle. A collaborator in Basel can rerun your exact experiment on their own hardware.",
273 stat: { value: "94%", label: "of shared runs reproduced within ±5% on first attempt" },
274 },
275];
276
277// 16 × 24 plate rendered as 8 × 12 for clarity
278const ROWS = 8;
279const COLS = 12;
280const hash = (i: number) => {
281 const x = Math.sin(i * 127.1 + 311.7) * 43758.5453;
282 return x - Math.floor(x);
283};
284
285function HelixMedia({ active }: { active: number }) {
286 const wells = [];
287 for (let r = 0; r < ROWS; r++)
288 for (let c = 0; c < COLS; c++) {
289 const i = r * COLS + c;
290 const conc = c / (COLS - 1);
291 const resp = 1 / (1 + Math.pow(0.35 / Math.max(0.02, conc), 2.4));
292 const ctrl = c === 0 || c === COLS - 1;
293 const outlier = i === 41 || i === 77;
294 wells.push(
295 <span
296 key={i}
297 className="fd-sst-demo-well"
298 data-ctrl={ctrl ? "" : undefined}
299 data-out={outlier ? "" : undefined}
300 style={{
301 ["--r" as string]: r,
302 ["--c" as string]: c,
303 ["--v" as string]: (resp * (0.85 + hash(i) * 0.3)).toFixed(3),
304 ["--d" as string]: `${Math.round((r + c) * 18)}ms`,
305 }}
306 />,
307 );
308 }
309 // dose–response points
310 const pts = Array.from({ length: 12 }, (_, i) => {
311 const x = i / 11;
312 const y = 1 / (1 + Math.pow(0.42 / Math.max(0.03, x), 2.6));
313 return { x: 40 + x * 320, y: 250 - (y * (0.92 + hash(i + 9) * 0.16)) * 200 };
314 });
315 const curve = Array.from({ length: 60 }, (_, i) => {
316 const x = i / 59;
317 const y = 1 / (1 + Math.pow(0.42 / Math.max(0.03, x), 2.6));
318 return `${i ? "L" : "M"}${(40 + x * 320).toFixed(1)} ${(250 - y * 200).toFixed(1)}`;
319 }).join(" ");
320
321 return (
322 <div className="fd-sst-demo-stage" data-state={active}>
323 <div className="fd-sst-demo-bgl" />
324 {/* state 0: protocol editor */}
325 <div className="fd-sst-demo-layer fd-sst-demo-proto" data-active={active === 0 ? "" : undefined}>
326 <div className="fd-sst-demo-win">
327 <div className="fd-sst-demo-winbar">
328 <i />
329 <i />
330 <i />
331 <span>kinase-panel-v3.helix</span>
332 </div>
333 <div className="fd-sst-demo-code">
334 <p>
335 <b>test</b> 12 compounds <em>against</em> KRAS-G12C
336 </p>
337 <p>
338 <b>dose</b> 10 µM → 0.5 nM, <em>half-log</em>, 11 points
339 </p>
340 <p>
341 <b>replicates</b> 3 · <b>controls</b> DMSO, sotorasib
342 </p>
343 <p>
344 <b>readout</b> luminescence @ 24 h
345 </p>
346 <p className="fd-sst-demo-ok">✓ compiled · 384 wells · 2 plates</p>
347 </div>
348 </div>
349 </div>
350 {/* state 1+2: plate */}
351 <div className="fd-sst-demo-layer fd-sst-demo-plate" data-active={active === 1 || active === 2 ? "" : undefined}>
352 <div className="fd-sst-demo-platebox">
353 <div className="fd-sst-demo-wells">{wells}</div>
354 <div className="fd-sst-demo-head">
355 <span>HX-0412 · 1/2</span>
356 <span className="fd-sst-demo-live">{active >= 2 ? "2 outliers flagged" : "Dispensing · row F"}</span>
357 </div>
358 </div>
359 </div>
360 {/* state 3: curve + bundle */}
361 <div className="fd-sst-demo-layer fd-sst-demo-curve" data-active={active === 3 ? "" : undefined}>
362 <svg viewBox="0 0 400 290" className="fd-sst-demo-svg" aria-hidden="true">
363 {[0, 1, 2, 3, 4].map((i) => (
364 <line key={i} x1="40" x2="360" y1={50 + i * 50} y2={50 + i * 50} stroke="rgba(255,255,255,.08)" />
365 ))}
366 <path d={curve} className="fd-sst-demo-path" pathLength={1} />
367 {pts.map((p, i) => (
368 <circle key={i} cx={p.x} cy={p.y} r="4.5" className="fd-sst-demo-pt" style={{ ["--d" as string]: `${i * 40}ms` }} />
369 ))}
370 <text x="40" y="280" className="fd-sst-demo-ax">
371 0.5 nM
372 </text>
373 <text x="360" y="280" textAnchor="end" className="fd-sst-demo-ax">
374 10 µM
375 </text>
376 </svg>
377 <div className="fd-sst-demo-card">
378 <span className="fd-sst-demo-card-k">IC₅₀</span>
379 <span className="fd-sst-demo-card-v">42.7 nM</span>
380 <span className="fd-sst-demo-card-s">Signed bundle · sha 9f3c…a10e</span>
381 </div>
382 </div>
383 </div>
384 );
385}
386
387export function Demo() {
388 return (
389 <div className="fd-sst-demo">
390 <style>{demoCss}</style>
391 <StickyStory
392 steps={STEPS}
393 renderMedia={(a) => <HelixMedia active={a} />}
394 header={
395 <header className="fd-sst-demo-hero">
396 <p className="fd-sst-demo-kicker">Helix Bio — the lab operating system</p>
397 <h2 className="fd-sst-demo-h">From hypothesis to signed result in one loop.</h2>
398 <p className="fd-sst-demo-cue" aria-hidden="true">
399 <span className="fd-sst-demo-cue-line" />
400 Scroll
401 </p>
402 </header>
403 }
404 />
405 </div>
406 );
407}
408
409const demoCss = `
410.fd-sst-demo{display:block;margin:0;padding:0;background:#F4F6F5;min-height:100vh}
411.fd-sst-demo-hero{max-width:1240px;margin:0 auto;padding:clamp(48px,10vh,120px) clamp(16px,4vw,56px) 0;text-align:left}
412.fd-sst-demo-kicker{margin:0 0 14px;padding:0;color:#0E9F8E;font:600 0.875rem/1.2 ui-sans-serif,-apple-system,"Inter",sans-serif;letter-spacing:0}
413.fd-sst-demo-h{margin:0;padding:0;max-width:880px;color:#0B1412;font:650 clamp(2.25rem,6vw,4.5rem)/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.045em}
414.fd-sst-demo-cue{display:flex;width:fit-content;align-items:center;gap:10px;margin:28px 0 0;padding:0;color:#5D6B67;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}
415.fd-sst-demo-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(11,20,18,.15)}
416.fd-sst-demo-cue-line::after{content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#0B1412;animation:fd-sst-cue 1.6s cubic-bezier(0.65,0,0.35,1) infinite}
417@keyframes fd-sst-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}
418
419.fd-sst-demo-stage{position:absolute;inset:0;overflow:hidden;container-type:size;color:#E8F2EF;font-family:ui-sans-serif,-apple-system,"Inter",sans-serif}
420.fd-sst-demo-bgl{
421 position:absolute;inset:-20%;
422 background:
423 radial-gradient(40% 40% at calc(30% + var(--fd-sst-p) * 40%) 25%,rgba(14,159,142,.55),transparent 70%),
424 radial-gradient(45% 45% at calc(80% - var(--fd-sst-p) * 50%) 85%,rgba(91,108,255,.4),transparent 70%),
425 #0B1412;
426}
427.fd-sst-demo-layer{
428 position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:8%;
429 opacity:0;transform:scale(.94) translate3d(0,16px,0);filter:blur(6px);
430 transition:opacity var(--fd-dur-4) var(--fd-ease-out),transform var(--fd-dur-4) var(--fd-ease-out),filter var(--fd-dur-3) var(--fd-ease-out);
431 pointer-events:none;
432}
433.fd-sst-demo-layer[data-active]{opacity:1;transform:none;filter:none}
434.fd-sst-demo-win{width:100%;max-width:min(440px,100cqh * 1.25);border-radius:18px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);
435 -webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);overflow:hidden;box-shadow:0 30px 60px -30px rgba(0,0,0,.6)}
436.fd-sst-demo-winbar{display:flex;align-items:center;gap:6px;padding:12px 14px;border-bottom:1px solid rgba(255,255,255,.08);font:500 0.75rem/1 ui-monospace,"SF Mono",Menlo,monospace;color:rgba(232,242,239,.6)}
437.fd-sst-demo-winbar i{display:block;width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.18)}
438.fd-sst-demo-winbar span{margin-left:8px}
439.fd-sst-demo-code{padding:clamp(10px,4cqh,18px) 18px clamp(12px,4.4cqh,20px);display:grid;gap:clamp(4px,2.2cqh,10px)}
440.fd-sst-demo-code p{margin:0;padding:0;font:400 clamp(0.625rem,3.4cqh,0.9375rem)/1.45 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;color:#E8F2EF;
441 opacity:0;transform:translate3d(-8px,0,0);transition:opacity var(--fd-dur-3) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}
442.fd-sst-demo-proto[data-active] .fd-sst-demo-code p{opacity:1;transform:none}
443.fd-sst-demo-code p:nth-child(2){transition-delay:90ms}.fd-sst-demo-code p:nth-child(3){transition-delay:180ms}
444.fd-sst-demo-code p:nth-child(4){transition-delay:270ms}.fd-sst-demo-code p:nth-child(5){transition-delay:420ms}
445.fd-sst-demo-code b{font-weight:600;color:#5EEAD4}
446.fd-sst-demo-code em{font-style:normal;color:#A5B4FC}
447.fd-sst-demo-ok{color:#86EFAC !important;margin-top:6px !important}
448
449.fd-sst-demo-platebox{width:100%;max-width:min(480px,86cqh * 1.3);padding:18px;border-radius:20px;background:linear-gradient(160deg,#E7ECEA,#C9D2CF);box-shadow:0 30px 60px -30px rgba(0,0,0,.7),inset 0 1px 0 rgba(255,255,255,.8)}
450.fd-sst-demo-wells{display:grid;grid-template-columns:repeat(${COLS},1fr);gap:clamp(3px,.6vw,6px)}
451.fd-sst-demo-well{
452 display:block;aspect-ratio:1;border-radius:50%;
453 background:radial-gradient(circle at 40% 35%,rgba(255,255,255,.7),rgba(255,255,255,0) 50%),#AEB9B5;
454 box-shadow:inset 0 1px 2px rgba(0,0,0,.25);
455 position:relative;overflow:hidden;
456}
457.fd-sst-demo-well::after{
458 content:"";position:absolute;inset:0;border-radius:50%;
459 background:color-mix(in srgb,#0E9F8E calc(var(--v) * 100%),#E9F7A8);
460 transform:scale(0);opacity:0;
461 transition:transform var(--fd-dur-3) var(--fd-spring) var(--d),opacity var(--fd-dur-2) var(--fd-ease-out) var(--d),background var(--fd-dur-4) var(--fd-ease-out);
462}
463.fd-sst-demo-well[data-ctrl]::after{background:#64748B}
464/* Run: rows dispense progressively, scrubbed by --fd-sst-k (fractional step) */
465.fd-sst-demo-stage[data-state="1"] .fd-sst-demo-well::after{
466 --fill:clamp(0,(var(--fd-sst-k) - 0.45 - var(--r) * 0.065) * 7,1);
467 transform:scale(calc(var(--fill) * .72));opacity:calc(var(--fill) * .9);background:#7DD3C7;
468 transition:background var(--fd-dur-4) var(--fd-ease-out);
469}
470.fd-sst-demo-stage[data-state="1"] .fd-sst-demo-well[data-ctrl]::after{background:#94A3B8}
471.fd-sst-demo-stage[data-state="2"] .fd-sst-demo-well::after{transform:scale(.86);opacity:1}
472.fd-sst-demo-stage[data-state="2"] .fd-sst-demo-well[data-out]{box-shadow:inset 0 1px 2px rgba(0,0,0,.25),0 0 0 2px #F43F5E}
473.fd-sst-demo-head{white-space:nowrap;display:flex;justify-content:space-between;gap:8px;margin:14px 2px 0;font:500 0.75rem/1.2 ui-monospace,"SF Mono",Menlo,monospace;color:#29403A}
474.fd-sst-demo-live{color:#0E7C6F}
475
476.fd-sst-demo-curve{flex-direction:column;gap:16px}
477.fd-sst-demo-svg{display:block;width:100%;max-width:min(460px,64cqh * 1.38);height:auto;overflow:visible}
478.fd-sst-demo-path{fill:none;stroke:#5EEAD4;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset 1100ms var(--fd-ease-in-out) 200ms}
479.fd-sst-demo-curve[data-active] .fd-sst-demo-path{stroke-dashoffset:0}
480.fd-sst-demo-pt{fill:#0B1412;stroke:#E8F2EF;stroke-width:2;opacity:0;transform-box:fill-box;transform-origin:center;transform:scale(0);
481 transition:opacity var(--fd-dur-2) var(--fd-ease-out) var(--d),transform var(--fd-dur-3) var(--fd-spring) var(--d)}
482.fd-sst-demo-curve[data-active] .fd-sst-demo-pt{opacity:1;transform:none}
483.fd-sst-demo-ax{fill:rgba(232,242,239,.5);font:500 11px ui-monospace,"SF Mono",Menlo,monospace}
484.fd-sst-demo-card{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px;padding:12px 16px;border-radius:14px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12)}
485.fd-sst-demo-card-k{font:500 0.75rem/1 ui-monospace,"SF Mono",Menlo,monospace;color:rgba(232,242,239,.6)}
486.fd-sst-demo-card-v{font:650 1.375rem/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.02em;color:#fff}
487.fd-sst-demo-card-s{font:500 0.6875rem/1.2 ui-monospace,"SF Mono",Menlo,monospace;color:#86EFAC}
488@media (max-width:760px){
489 .fd-sst-demo-hero{padding-bottom:12px}
490 .fd-sst-demo-layer{padding:6%}
491 .fd-sst-demo-platebox{padding:10px}
492 .fd-sst-demo-head{margin-top:8px;font-size:0.625rem}
493 .fd-sst-demo-curve{gap:8px}
494 .fd-sst-demo-card{padding:8px 12px}
495 .fd-sst-demo-card-v{font-size:1.0625rem}
496 .fd-sst-demo-winbar{padding:8px 12px}
497}
498@media (prefers-reduced-motion: reduce){
499 .fd-sst-demo-layer{transform:none !important;filter:none !important}
500 .fd-sst-demo-cue-line::after{animation:none}
501}
502`;

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.

Text Reveal on Scroll

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