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
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. The3 * left column is a numbered list of steps with a progress rail; the right4 * column stays pinned and crossfades between product visuals built in code5 * (terminal, schema diff, query result, deploy log) as each step crosses the6 * middle of the viewport. On narrow screens each visual sits inline under its7 * 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 and15 * sets the active step plus a rail fill (transform only). Lines inside the16 * active visual stagger in with CSS transitions.17 * Accessibility: steps are an ordered list; each step title is a button that18 * scrolls its step into view; the pinned visual is aria-hidden because every19 * 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}> </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}> </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=></span> select id, email, plan, sum(amount_minor)/100.0</L>103 <L i={1}><span className="fd-stt-am"> -></span> from customers join invoices using (customer_id)</L>104 <L i={2}><span className="fd-stt-am"> -></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.
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.


