Pinned timeline
Pin the section; the scroll draws the line, counts the year and lights each milestone over its own slice of the pin.
years of shipping
- 2019
First client site on a hand-rolled scroll engine.
- 2021
The engine becomes a package; five sites share one codebase.
- 2024
Benchmarks published, CSS-variable API frozen.
- 2026
ScrollVars ships on npm. This timeline is one pinned block and four ranges.
// ScrollVars fx · timeline-scrub
// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (sv-stage, sv-range)
// Pinned: the scroll draws the line, counts the year (a CSS counter) and lights each
// milestone over its own slice of the pin. No JS beyond the driver.
// The CSS below is one constant string injected as raw HTML: React 18's server
// renderer escapes a <style> child (`>` becomes `>`) and <style> is raw text, so
// the entity never decodes and every child-combinator rule is dropped. CSP is
// unchanged from any inline <style>: a style-src nonce or hash, or lift the string
// into your own stylesheet.
'use client'
import * as React from 'react'
import { Track } from 'scrollvars/react'
const css = `
.sv-timeline .tl-sticky { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); align-items: center; gap: 40px; padding: 32px clamp(20px, 5vw, 64px); box-sizing: border-box; min-height: 100%; }
.sv-timeline .tl-year { font-size: clamp(64px, 12vw, 150px); line-height: 1; font-weight: 700; letter-spacing: -.04em; font-variant-numeric: tabular-nums;
counter-reset: tl-year calc(var(--tl-from) + var(--sv-pin, 1) * var(--tl-span)); }
.sv-timeline .tl-year .tl-count::after { content: counter(tl-year); }
.sv-timeline .tl-cap { display: block; margin-top: 10px; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; opacity: .7; }
.sv-timeline .tl-track { position: relative; padding-left: 34px; }
.sv-timeline .tl-line { position: absolute; left: 8px; top: 8px; bottom: 8px; width: 2px; background: rgba(128,128,128,.25); }
.sv-timeline .tl-line::after { content: ""; position: absolute; inset: 0; background: currentColor; transform-origin: top; scale: 1 var(--sv-pin, 1); }
.sv-timeline .tl-items { list-style: none; margin: 0; padding: 0; display: grid; gap: clamp(18px, 4vh, 40px); }
.sv-timeline .tl-items > li { position: relative; --sv-distance: 1.6rem; }
.sv-timeline .tl-items > li::before { content: ""; position: absolute; left: -32px; top: 6px; width: 12px; height: 12px; border-radius: 50%;
background: color-mix(in oklab, currentColor calc(var(--sv-r, 1) * 100%), rgba(128,128,128,.35)); }
.sv-timeline .tl-items b { display: block; font-size: 12px; letter-spacing: .12em; margin-bottom: 4px; }
.sv-timeline .tl-items p { margin: 0; max-width: 34ch; }
[data-sv-flow].sv-timeline .tl-sticky { min-height: 0; --sv-pin: 1; }
/* under reduced motion the stage is in flow and the driver keeps writing --sv-pin:
settle the year and the line at their finished value, like the shipped presets */
@media (prefers-reduced-motion: reduce) { .sv-timeline .tl-sticky { --sv-pin: 1; } }
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .sv-timeline .tl-sticky { --sv-pin: 1; }
@media (max-width: 640px) { .sv-timeline .tl-sticky { grid-template-columns: 1fr; align-content: center; gap: 22px; } }
`
// aria-label is prohibited on generic roles (p/span/div). Axe
// `aria-prohibited-attr`: so the readable text is a visually-hidden child.
const SR_ONLY: React.CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
}
export interface TimelineStep {
year: number
text: React.ReactNode
/** Slice of the pin (0..1) this milestone animates over; defaults to evenly staggered, overlapping. */
range?: [number, number]
}
export function TimelineScrub({
steps,
caption = 'years of shipping',
height = '320vh',
className,
nonce,
}: {
steps: TimelineStep[]
caption?: string
/** Scroll length of the pinned stretch. */
height?: string
className?: string
nonce?: string
}) {
const from = steps[0]?.year ?? 0
const span = (steps[steps.length - 1]?.year ?? from) - from
const n = steps.length
return (
<Track
pin={height}
className={className ? 'sv-timeline ' + className : 'sv-timeline'}
style={{ '--tl-from': from, '--tl-span': span } as React.CSSProperties}
>
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
<div className="sv-stage"><div data-sv-fit="" className="tl-sticky">
<div>
<span className="tl-year">
<span style={SR_ONLY}>{from + span}</span>
<span className="tl-count" aria-hidden="true" />
</span>
<span className="tl-cap">{caption}</span>
</div>
<div className="tl-track">
<i className="tl-line" />
<ol className="sv-range sv-range-rise tl-items">
{steps.map((s, i) => {
const [a, b] = s.range ?? [i / n, Math.min((i + 1.4) / n, 1)]
return (
<li key={s.year} style={{ '--sv-from': a, '--sv-to': b } as React.CSSProperties}>
<b>{s.year}</b>
<p>{s.text}</p>
</li>
)
})}
</ol>
</div>
</div></div>
</Track>
)
}