Skip to content

Sticky Feature Tour

Scroll-driven feature tour with a pinned window that crossfades terminal, diff and query UIs, steps on a progress rail.

  • Feature Sections
  • Developer tools
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (position: sticky). Under 960px each visual sits inline under its step.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/sticky-feature-tour.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/features/sticky-feature-tour.tsx
1/**
2 * StickyFeatureTour, a scroll-driven product tour for a developer tool. The
3 * left column is a numbered list of steps with a progress rail; the right
4 * column stays pinned and crossfades between product visuals built in code
5 * (terminal, schema diff, query result, deploy log) as each step crosses the
6 * middle of the viewport. On narrow screens each visual sits inline under its
7 * step instead.
8 *
9 * Props: eyebrow, title, lede, steps ({ id, kicker, title, body, meta, visual }).
10 * `visual` is any ReactNode, so you can drop in screenshots or your own UI.
11 * CSS vars: --fd-stt-bg, --fd-stt-ink, --fd-stt-amber.
12 *
13 * Browser support: position: sticky (all evergreen browsers).
14 * Motion: one rAF-throttled scroll listener reads getBoundingClientRect and
15 * sets the active step plus a rail fill (transform only). Lines inside the
16 * active visual stagger in with CSS transitions.
17 * Accessibility: steps are an ordered list; each step title is a button that
18 * scrolls its step into view; the pinned visual is aria-hidden because every
19 * step carries a text description (the inline mobile copy is the same UI).
20 * Reduced motion: no translate, no stagger, panes swap with a short fade.
21 */
22import { useEffect, useId, useRef, useState } from "react";
23import type { ReactNode } from "react";
24
25export interface SttStep {
26 id: string;
27 kicker: string;
28 title: string;
29 body: ReactNode;
30 meta?: string[];
31 visual: ReactNode;
32 file?: string;
33}
34
35export interface StickyFeatureTourProps {
36 eyebrow?: ReactNode;
37 title?: ReactNode;
38 lede?: ReactNode;
39 steps?: SttStep[];
40 className?: string;
41}
42
43/* ───────────── visuals ───────────── */
44const L = ({ i, c, children }: { i: number; c?: string; children: ReactNode }) => (
45 <div className={`fd-stt-ln${c ? " fd-stt-" + c : ""}`} style={{ ["--i" as string]: i }}>{children}</div>
46);
47
48function TerminalVisual() {
49 return (
50 <div className="fd-stt-term">
51 <L i={0}><span className="fd-stt-ps">~/api</span> <span className="fd-stt-am">$</span> tide branch create pr-482 --from main</L>
52 <L i={1} c="dim"> resolving main@a91f3e2 ............ 212.4 GB</L>
53 <L i={2} c="dim"> copy-on-write snapshot ............. <span className="fd-stt-ok">done</span> 1.8s</L>
54 <L i={3} c="dim"> compute: 2 vCPU, scales to zero after 5 min idle</L>
55 <L i={4}>&nbsp;</L>
56 <L i={5}><span className="fd-stt-ok">●</span> branch <b>pr-482</b> is ready</L>
57 <L i={6}><span className="fd-stt-dim"> url</span> postgres://pr-482.eu-central.tidepool.dev/api</L>
58 <L i={7}><span className="fd-stt-dim"> cost</span> €0.004 / hour while awake</L>
59 <L i={8}>&nbsp;</L>
60 <L i={9}><span className="fd-stt-ps">~/api</span> <span className="fd-stt-am">$</span> <span className="fd-stt-caret" /></L>
61 </div>
62 );
63}
64
65function DiffVisual() {
66 const rows: [string, string, string][] = [
67 [" ", "41", "create table invoices ("],
68 [" ", "42", " id uuid primary key,"],
69 [" ", "43", " customer_id uuid not null references customers,"],
70 ["-", "44", " amount integer not null,"],
71 ["+", "44", " amount_minor bigint not null,"],
72 ["+", "45", " currency char(3) not null default 'EUR',"],
73 [" ", "46", " issued_at timestamptz not null default now()"],
74 [" ", "47", ");"],
75 [" ", "48", ""],
76 ["+", "49", "create index concurrently invoices_customer_idx"],
77 ["+", "50", " on invoices (customer_id, issued_at desc);"],
78 ];
79 return (
80 <div className="fd-stt-diff">
81 <div className="fd-stt-diff-h"><span>migrations/0042_invoice_currency.sql</span><span><b className="fd-stt-ok">+4</b> <b className="fd-stt-bad">−1</b></span></div>
82 {rows.map(([s, n, t], i) => (
83 <L key={i} i={i} c={s === "+" ? "add" : s === "-" ? "del" : undefined}>
84 <span className="fd-stt-gut">{n}</span><span className="fd-stt-sign">{s}</span>{t}
85 </L>
86 ))}
87 <L i={12} c="note"><span className="fd-stt-am">▲</span> tide check: index built concurrently, no table lock. Safe for 1.2M rows.</L>
88 </div>
89 );
90}
91
92function QueryVisual() {
93 const rows = [
94 ["c_81f2", "m•••@k•••.de", "Team", "€1,240.00"],
95 ["c_1a07", "j•••@o•••.io", "Scale", "€8,910.50"],
96 ["c_77c3", "a•••@f•••.se", "Team", "€412.00"],
97 ["c_3e90", "l•••@h•••.nl", "Starter", "€39.00"],
98 ["c_c214", "s•••@v•••.ch", "Scale", "€12,077.25"],
99 ];
100 return (
101 <div className="fd-stt-q">
102 <L i={0}><span className="fd-stt-am">pr-482=&gt;</span> select id, email, plan, sum(amount_minor)/100.0</L>
103 <L i={1}><span className="fd-stt-am"> -&gt;</span> from customers join invoices using (customer_id)</L>
104 <L i={2}><span className="fd-stt-am"> -&gt;</span> group by 1,2,3 order by 4 desc limit 5;</L>
105 <div className="fd-stt-tbl" style={{ ["--i" as string]: 3 }}>
106 <div className="fd-stt-tr fd-stt-th"><span>id</span><span>email</span><span>plan</span><span>total</span></div>
107 {rows.map((r, i) => (
108 <div key={r[0]} className="fd-stt-tr fd-stt-ln" style={{ ["--i" as string]: 4 + i }}>
109 {r.map((c, k) => <span key={k} className={k === 1 ? "fd-stt-mask" : undefined}>{c}</span>)}
110 </div>
111 ))}
112 </div>
113 <L i={10} c="dim">(5 rows, 14 ms) · PII masked by policy <span className="fd-stt-am">mask_emails</span></L>
114 </div>
115 );
116}
117
118function DeployVisual() {
119 const steps = [
120 ["Schema check", "0 blocking locks", "0.4s"],
121 ["Backfill amount_minor", "1,204,388 rows", "38.2s"],
122 ["Swap reads", "0 ms write pause", "0.1s"],
123 ["Drop amount", "scheduled after 24h", "queued"],
124 ];
125 return (
126 <div className="fd-stt-dep">
127 <div className="fd-stt-dep-h">
128 <span><b>Merge pr-482</b> into main</span>
129 <span className="fd-stt-pill">Live</span>
130 </div>
131 {steps.map(([a, b, c], i) => (
132 <div key={a} className="fd-stt-dep-r fd-stt-ln" data-q={c === "queued" ? "" : undefined} style={{ ["--i" as string]: i * 2 }}>
133 <span className="fd-stt-dep-ic">{c === "queued" ? "" : "✓"}</span>
134 <span className="fd-stt-dep-a">{a}<small>{b}</small></span>
135 <span className="fd-stt-dep-t">{c}</span>
136 </div>
137 ))}
138 <div className="fd-stt-spark fd-stt-ln" style={{ ["--i" as string]: 9 }}>
139 <span>p95 latency, main</span>
140 <svg viewBox="0 0 240 40" preserveAspectRatio="none">
141 <path d="M0 26 L20 25 L40 27 L60 24 L80 26 L100 25 L120 27 L140 25 L160 26 L180 24 L200 26 L220 25 L240 26" />
142 <line x1="132" x2="132" y1="0" y2="40" />
143 </svg>
144 <span className="fd-stt-dim">flat through the swap, 11.8 ms</span>
145 </div>
146 </div>
147 );
148}
149
150const DEFAULT_STEPS: SttStep[] = [
151 {
152 id: "branch", kicker: "Branch", file: "zsh", title: "A full copy of production in under two seconds.",
153 body: "Open a pull request and Tidepool forks your database with it. Copy-on-write, so a 200 GB branch costs you the rows you change, not the rows you have.",
154 meta: ["1.8s for 212 GB", "Scales to zero"], visual: <TerminalVisual />,
155 },
156 {
157 id: "migrate", kicker: "Migrate", file: "0042_invoice_currency.sql", title: "Read the migration as a diff, not a prayer.",
158 body: "Schema changes show up next to your code review. tide check flags table locks, missing concurrent indexes and long backfills before anyone hits merge.",
159 meta: ["17 lock rules", "Runs in CI"], visual: <DiffVisual />,
160 },
161 {
162 id: "query", kicker: "Preview", file: "psql", title: "Real data, with the personal bits blurred.",
163 body: "Branches carry production-shaped data. Masking policies rewrite emails, names and IBANs on the way out, so your preview app and your contractor see the same shape and none of the secrets.",
164 meta: ["GDPR masking", "Per-column policies"], visual: <QueryVisual />,
165 },
166 {
167 id: "merge", kicker: "Merge", file: "deploy · main", title: "Ship it without a maintenance window.",
168 body: "Merging replays the migration on main in expand and contract phases. Reads swap over atomically; the old column waits 24 hours in case you change your mind.",
169 meta: ["0 ms write pause", "24h undo"], visual: <DeployVisual />,
170 },
171];
172
173export function StickyFeatureTour({
174 eyebrow = "tidepool / how it works",
175 title = <>Every pull request<br />gets its own <span>database.</span></>,
176 lede = "Postgres that branches like git. Four steps from a laptop to production, none of them a staging server.",
177 steps = DEFAULT_STEPS,
178 className,
179}: StickyFeatureTourProps) {
180 const [active, setActive] = useState(0);
181 const listRef = useRef<HTMLOListElement>(null);
182 const railRef = useRef<HTMLSpanElement>(null);
183 const tid = useId();
184
185 useEffect(() => {
186 const list = listRef.current;
187 if (!list) return;
188 let raf = 0;
189 const measure = () => {
190 raf = 0;
191 const items = Array.from(list.querySelectorAll<HTMLElement>("[data-step]"));
192 const mid = window.innerHeight * 0.5;
193 let a = 0;
194 items.forEach((el, i) => { if (el.getBoundingClientRect().top < mid) a = i; });
195 setActive(a);
196 const lr = list.getBoundingClientRect();
197 const p = Math.max(0, Math.min(1, (mid - lr.top) / Math.max(1, lr.height)));
198 if (railRef.current) railRef.current.style.transform = `scaleY(${p})`;
199 };
200 const on = () => { if (!raf) raf = requestAnimationFrame(measure); };
201 measure();
202 window.addEventListener("scroll", on, { passive: true });
203 window.addEventListener("resize", on);
204 return () => { window.removeEventListener("scroll", on); window.removeEventListener("resize", on); cancelAnimationFrame(raf); };
205 }, [steps.length]);
206
207 const go = (i: number) => {
208 const el = listRef.current?.querySelectorAll<HTMLElement>("[data-step]")[i];
209 if (!el) return;
210 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
211 const y = el.getBoundingClientRect().top + window.scrollY - window.innerHeight * 0.3;
212 window.scrollTo({ top: y, behavior: reduce ? "auto" : "smooth" });
213 };
214
215 return (
216 <section className={`fd-stt${className ? " " + className : ""}`} aria-labelledby={tid}>
217 <style>{css}</style>
218 <header className="fd-stt-head">
219 <p className="fd-stt-eyebrow">{eyebrow}</p>
220 <h2 id={tid} className="fd-stt-title">{title}</h2>
221 <p className="fd-stt-lede">{lede}</p>
222 </header>
223 <div className="fd-stt-body">
224 <div className="fd-stt-left">
225 <div className="fd-stt-rail" aria-hidden="true"><span ref={railRef} /></div>
226 <ol className="fd-stt-steps" ref={listRef}>
227 {steps.map((s, i) => (
228 <li key={s.id} data-step="" className="fd-stt-step" data-on={i === active ? "" : undefined} data-past={i < active ? "" : undefined}>
229 <span className="fd-stt-node" aria-hidden="true">{String(i + 1).padStart(2, "0")}</span>
230 <p className="fd-stt-kicker">{s.kicker}</p>
231 <h3 className="fd-stt-h">
232 <button type="button" onClick={() => go(i)} aria-current={i === active ? "step" : undefined}>{s.title}</button>
233 </h3>
234 <p className="fd-stt-p">{s.body}</p>
235 {s.meta && <ul className="fd-stt-meta">{s.meta.map((m) => <li key={m}>{m}</li>)}</ul>}
236 <div className="fd-stt-inline" data-on="">
237 <Frame file={s.file} kicker={s.kicker} index={i} total={steps.length}>{s.visual}</Frame>
238 </div>
239 </li>
240 ))}
241 </ol>
242 </div>
243 <div className="fd-stt-right" aria-hidden="true">
244 <div className="fd-stt-sticky">
245 <div className="fd-stt-stack">
246 {steps.map((s, i) => (
247 <div key={s.id} className="fd-stt-pane" data-on={i === active ? "" : undefined} data-dir={i < active ? "up" : "down"}>
248 <Frame file={s.file} kicker={s.kicker} index={i} total={steps.length}>{s.visual}</Frame>
249 </div>
250 ))}
251 </div>
252 <div className="fd-stt-dots">
253 {steps.map((s, i) => <i key={s.id} data-on={i === active ? "" : undefined} />)}
254 <span>{String(active + 1).padStart(2, "0")} / {String(steps.length).padStart(2, "0")}</span>
255 </div>
256 </div>
257 </div>
258 </div>
259 </section>
260 );
261}
262
263function Frame({ file, kicker, index, total, children }: { file?: string; kicker: string; index: number; total: number; children: ReactNode }) {
264 return (
265 <div className="fd-stt-frame">
266 <div className="fd-stt-bar">
267 <span className="fd-stt-lights"><i /><i /><i /></span>
268 <span className="fd-stt-file">{file ?? kicker}</span>
269 <span className="fd-stt-idx">{index + 1}/{total}</span>
270 </div>
271 <div className="fd-stt-screen">{children}</div>
272 </div>
273 );
274}
275
276const css = `
277:where(.fd-stt,.fd-stt *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}
278:where(.fd-stt,.fd-stt *)::before,:where(.fd-stt,.fd-stt *)::after{box-sizing:border-box}
279:where(.fd-stt svg){display:block;flex-shrink:0}
280:where(.fd-stt button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer;text-align:left}
281.fd-stt{
282 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);
283 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
284 --fd-stt-bg:#0C0E0C;--fd-stt-ink:#ECE6D6;--fd-stt-mut:#8C8878;--fd-stt-dim:#5E5B50;--fd-stt-line:rgba(236,230,214,.1);--fd-stt-amber:#FFB224;--fd-stt-ok:#8BD17C;--fd-stt-bad:#F2766B;
285 --fd-stt-panel:#131612;--fd-stt-screen:#0A0B09;
286 --fd-stt-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
287 --fd-stt-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
288 display:block;width:100%;background:var(--fd-stt-bg);color:var(--fd-stt-ink);padding:clamp(64px,9vw,128px) clamp(16px,4vw,56px) clamp(64px,10vw,160px);
289 font:400 15px/1.5 var(--fd-stt-sans);-webkit-font-smoothing:antialiased;
290 background-image:radial-gradient(circle at 1px 1px,rgba(236,230,214,.06) 1px,transparent 0);background-size:24px 24px}
291.fd-stt-head{max-width:1240px;margin:0 auto clamp(48px,7vw,96px);display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px 48px;align-items:end}
292.fd-stt-eyebrow{grid-column:1 / -1;font:500 13px/16px var(--fd-stt-mono);color:var(--fd-stt-amber);letter-spacing:0}
293.fd-stt-eyebrow::before{content:"";display:inline-block;width:8px;height:8px;margin-right:10px;background:var(--fd-stt-amber);vertical-align:0}
294.fd-stt-title{font:500 clamp(2.25rem,5.2vw,4.5rem)/1 var(--fd-stt-mono);letter-spacing:-.06em;color:var(--fd-stt-ink)}
295.fd-stt-title span{color:var(--fd-stt-amber)}
296.fd-stt-lede{font:400 16px/1.55 var(--fd-stt-sans);color:var(--fd-stt-mut)}
297.fd-stt-body{max-width:1240px;margin:0 auto;display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:72px}
298.fd-stt-left{position:relative;padding-left:64px}
299.fd-stt-rail{position:absolute;left:19px;top:8px;bottom:0;width:1px;background:var(--fd-stt-line)}
300.fd-stt-rail span{display:block;width:100%;height:100%;background:var(--fd-stt-amber);transform-origin:top;transform:scaleY(0)}
301.fd-stt-step{position:relative;min-height:62vh;padding-bottom:64px;opacity:.42;transition:opacity var(--fd-dur-3) var(--fd-ease-out)}
302.fd-stt-step:last-child{min-height:48vh}
303.fd-stt-step[data-on]{opacity:1}
304.fd-stt-node{position:absolute;left:-64px;top:0;width:40px;height:26px;display:grid;place-items:center;border-radius:4px;background:var(--fd-stt-bg);box-shadow:inset 0 0 0 1px var(--fd-stt-line);font:500 12px/1 var(--fd-stt-mono);color:var(--fd-stt-mut);transition:background-color var(--fd-dur-2) var(--fd-ease-in-out),color var(--fd-dur-2)}
305.fd-stt-step[data-on] .fd-stt-node,.fd-stt-step[data-past] .fd-stt-node{background:var(--fd-stt-amber);color:#1A1405;box-shadow:none}
306.fd-stt-kicker{font:500 12px/26px var(--fd-stt-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--fd-stt-mut)}
307.fd-stt-h{margin-top:12px;font:600 clamp(1.5rem,2.4vw,2rem)/1.15 var(--fd-stt-sans);letter-spacing:-.03em;color:var(--fd-stt-ink)}
308.fd-stt-h button{display:block;font:inherit;color:inherit;border-radius:4px}
309.fd-stt-h button:hover{color:#fff}
310.fd-stt-h button:focus-visible{outline:2px solid rgba(255,178,36,.7);outline-offset:4px}
311.fd-stt-p{margin-top:16px;max-width:46ch;font:400 16px/1.6 var(--fd-stt-sans);color:var(--fd-stt-mut)}
312.fd-stt-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:20px}
313.fd-stt-meta li{font:500 12px/16px var(--fd-stt-mono);padding:5px 9px;border-radius:4px;color:var(--fd-stt-ink);box-shadow:inset 0 0 0 1px var(--fd-stt-line)}
314.fd-stt-inline{display:none;margin-top:28px}
315.fd-stt-right{position:relative}
316.fd-stt-sticky{position:sticky;top:calc(50vh - 230px);height:auto}
317.fd-stt-stack{position:relative;height:420px}
318.fd-stt-pane{position:absolute;inset:0;opacity:0;transform:translateY(18px) scale(.985);transition:opacity var(--fd-dur-3) var(--fd-ease-in-out),transform var(--fd-dur-4) var(--fd-ease-out);pointer-events:none}
319.fd-stt-pane[data-dir="up"]{transform:translateY(-18px) scale(.985)}
320.fd-stt-pane[data-on]{opacity:1;transform:none}
321.fd-stt-dots{display:flex;align-items:center;gap:6px;margin-top:20px;font:500 12px/16px var(--fd-stt-mono);color:var(--fd-stt-mut)}
322.fd-stt-dots i{display:block;width:18px;height:2px;background:var(--fd-stt-line);transition:background-color var(--fd-dur-2),width var(--fd-dur-3) var(--fd-ease-out)}
323.fd-stt-dots i[data-on]{background:var(--fd-stt-amber);width:36px}
324.fd-stt-dots span{margin-left:auto}
325.fd-stt-frame{height:100%;display:flex;flex-direction:column;border-radius:12px;background:var(--fd-stt-panel);box-shadow:0 0 0 1px var(--fd-stt-line),0 40px 80px -40px rgba(0,0,0,.8);overflow:hidden}
326.fd-stt-bar{display:flex;align-items:center;gap:14px;height:38px;padding:0 14px;border-bottom:1px solid var(--fd-stt-line);font:500 12px/1 var(--fd-stt-mono);color:var(--fd-stt-mut)}
327.fd-stt-lights{display:flex;gap:6px}
328.fd-stt-lights i{display:block;width:9px;height:9px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(236,230,214,.22)}
329.fd-stt-file{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fd-stt-ink)}
330.fd-stt-screen{flex:1;padding:20px 22px;background:var(--fd-stt-screen);font:400 13px/22px var(--fd-stt-mono);color:var(--fd-stt-ink);overflow:hidden}
331.fd-stt-ln{white-space:pre;overflow:hidden;text-overflow:ellipsis;opacity:0;transform:translateX(-6px);transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out);transition-delay:0ms}
332[data-on]>.fd-stt-frame .fd-stt-ln,.fd-stt-inline .fd-stt-ln{opacity:1;transform:none;transition-delay:calc(180ms + var(--i) * 55ms)}
333.fd-stt-dim,.fd-stt-ln.fd-stt-dim{color:var(--fd-stt-dim)}
334.fd-stt-ps{color:var(--fd-stt-mut)}
335.fd-stt-am{color:var(--fd-stt-amber)}
336.fd-stt-ok{color:var(--fd-stt-ok)}
337.fd-stt-bad{color:var(--fd-stt-bad)}
338.fd-stt-term b{font-weight:700;color:#fff}
339.fd-stt-caret{display:inline-block;width:8px;height:15px;vertical-align:-3px;background:var(--fd-stt-amber);animation:fd-stt-blink 1s steps(1) infinite}
340@keyframes fd-stt-blink{50%{opacity:0}}
341.fd-stt-diff{margin:-20px -22px}
342.fd-stt-diff-h{display:flex;justify-content:space-between;padding:10px 22px;border-bottom:1px solid var(--fd-stt-line);font:500 12px/18px var(--fd-stt-mono);color:var(--fd-stt-mut)}
343.fd-stt-diff-h b{font-weight:600}
344.fd-stt-diff .fd-stt-ln{padding:0 22px 0 0}
345.fd-stt-gut{display:inline-block;width:44px;padding-right:12px;text-align:right;color:var(--fd-stt-dim)}
346.fd-stt-sign{display:inline-block;width:18px;color:var(--fd-stt-dim)}
347.fd-stt-add{background:rgba(139,209,124,.09);color:#CFEBC6}.fd-stt-add .fd-stt-sign{color:var(--fd-stt-ok)}
348.fd-stt-del{background:rgba(242,118,107,.09);color:#F1C3BD;text-decoration:line-through;text-decoration-color:rgba(242,118,107,.5)}.fd-stt-del .fd-stt-sign{color:var(--fd-stt-bad)}
349.fd-stt-note{margin:14px 22px 0;padding:10px 12px!important;border-radius:6px;background:rgba(255,178,36,.08);box-shadow:inset 0 0 0 1px rgba(255,178,36,.25);white-space:normal!important;color:var(--fd-stt-ink);line-height:18px}
350.fd-stt-tbl{margin-top:14px;border-top:1px solid var(--fd-stt-line)}
351.fd-stt-tr{display:grid;grid-template-columns:70px minmax(0,1fr) 70px 96px;gap:12px;padding:5px 0;border-bottom:1px solid var(--fd-stt-line)}
352.fd-stt-tr span:last-child{text-align:right}
353.fd-stt-th{color:var(--fd-stt-mut);font-size:12px}
354.fd-stt-mask{color:var(--fd-stt-amber);filter:none}
355.fd-stt-q>.fd-stt-ln:last-child{margin-top:12px}
356.fd-stt-dep-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;font:400 14px/20px var(--fd-stt-sans);color:var(--fd-stt-mut)}
357.fd-stt-dep-h b{font-weight:600;color:var(--fd-stt-ink)}
358.fd-stt-pill{font:600 11px/1 var(--fd-stt-mono);letter-spacing:.08em;text-transform:uppercase;padding:5px 8px;border-radius:4px;background:var(--fd-stt-ok);color:#0C1A08}
359.fd-stt-dep-r{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:12px;align-items:center;padding:9px 0;border-bottom:1px solid var(--fd-stt-line);white-space:normal}
360.fd-stt-dep-ic{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:rgba(139,209,124,.15);color:var(--fd-stt-ok);font-size:11px;line-height:1}
361.fd-stt-dep-r[data-q] .fd-stt-dep-ic{background:none;box-shadow:inset 0 0 0 1px var(--fd-stt-dim)}
362.fd-stt-dep-a{display:flex;flex-direction:column;font:500 13px/18px var(--fd-stt-sans)}
363.fd-stt-dep-a small{font:400 12px/16px var(--fd-stt-mono);color:var(--fd-stt-mut)}
364.fd-stt-dep-t{font:400 12px/16px var(--fd-stt-mono);color:var(--fd-stt-mut)}
365.fd-stt-dep-r[data-q] .fd-stt-dep-t{color:var(--fd-stt-amber)}
366.fd-stt-spark{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 16px;margin-top:16px;font:400 12px/16px var(--fd-stt-mono);color:var(--fd-stt-mut);white-space:normal}
367.fd-stt-spark svg{order:3;flex-basis:100%;width:100%;height:36px}
368.fd-stt-spark path{fill:none;stroke:var(--fd-stt-ok);stroke-width:1.5;vector-effect:non-scaling-stroke}
369.fd-stt-spark line{stroke:var(--fd-stt-amber);stroke-dasharray:2 3;vector-effect:non-scaling-stroke}
370@media (max-width:960px){
371 .fd-stt-head{grid-template-columns:1fr}
372 .fd-stt-body{grid-template-columns:minmax(0,1fr);gap:0}
373 .fd-stt-left,.fd-stt-step,.fd-stt-inline{min-width:0}
374 .fd-stt-right{display:none}
375 .fd-stt-inline{display:block}
376 .fd-stt-inline .fd-stt-frame{height:auto}
377 .fd-stt-step{opacity:1;min-height:0}
378 .fd-stt-left{padding-left:52px}
379 .fd-stt-node{left:-52px;width:34px}
380 .fd-stt-rail{left:16px}
381 .fd-stt-screen{padding:16px;font-size:11.5px;line-height:19px}
382 .fd-stt-diff{margin:-16px}
383 .fd-stt-tr{grid-template-columns:52px minmax(0,1fr) 52px 76px;gap:8px}
384}
385@media (max-width:520px){
386 .fd-stt-left{padding-left:0}
387 .fd-stt-rail{display:none}
388 .fd-stt-node{position:static;display:inline-grid;margin-bottom:12px}
389 .fd-stt-inline{margin-left:-4px;margin-right:-4px}
390}
391@media (prefers-reduced-motion:reduce){
392 .fd-stt-pane,.fd-stt-pane[data-dir="up"]{transform:none;transition:opacity 120ms linear}
393 .fd-stt-ln{transform:none;transition:none!important;opacity:1}
394 .fd-stt-caret{animation:none}
395 .fd-stt *{scroll-behavior:auto}
396}
397@media (prefers-contrast:more){.fd-stt-frame{box-shadow:0 0 0 1px currentColor}}
398`;
399
400export function Demo() {
401 return (
402 <div className="fd-sttd">
403 <style>{`.fd-sttd{background:#0C0E0C;min-height:100vh}.fd-sttd-nav{box-sizing:border-box;max-width:1352px;margin:0 auto;height:60px;padding:0 clamp(16px,4vw,56px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(236,230,214,.1);font:500 13px/18px ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;color:#8C8878}.fd-sttd-nav b{font-weight:700;color:#ECE6D6;letter-spacing:-.04em;font-size:16px}.fd-sttd-nav b i{font-style:normal;color:#FFB224}.fd-sttd-end{box-sizing:border-box;padding:56px 16px 96px;text-align:center;font:500 13px/18px ui-monospace,"SF Mono",Menlo,monospace;color:#8C8878;border-top:1px solid rgba(236,230,214,.1)}.fd-sttd-end b{color:#FFB224;font-weight:500}`}</style>
404 <div className="fd-sttd-nav"><b>tidepool<i>_</i></b><span>docs pricing status</span></div>
405 <StickyFeatureTour />
406 <div className="fd-sttd-end"><b>$</b> brew install tidepool/tap/tide</div>
407 </div>
408 );
409}

More Feature Sections. Same file contract.

Live UI Bento, features preview: Bento grid features section for React where every tile works: freeze a card, convert FX, clear approvals,…

Live UI Bento

Bento grid features section for React where every tile works: freeze a card, convert FX, clear approvals, scrub a chart.

Autoplay Feature Tabs, features preview: Autoplay feature tabs for React: tabs advance on a filling progress bar, pause on hover, and show a phone…

Autoplay Feature Tabs

Autoplay feature tabs for React: tabs advance on a filling progress bar, pause on hover, and show a phone screen built in code.

Hover Index List, features preview: Editorial index list for an architecture studio: a photo trails the cursor on a spring and rows open in place

Hover Index List

Editorial index list for an architecture studio: a photo trails the cursor on a spring and rows open in place.

Questions about Sticky Feature Tour

Run npx shadcn@latest add https://freedesignmd.com/r/sticky-feature-tour.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Scroll-driven feature tour with a pinned window that crossfades terminal, diff and query UIs, steps on a progress rail. It is a free, MIT-licensed React feature sections component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (position: sticky). Under 960px each visual sits inline under its step.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.