A zero-dependency, single-page visual builder for CSS scroll-driven animations: configure a timeline, watch it run on real elements, copy production-ready CSS.
CSS scroll-driven animations replaced a whole category of JavaScript, but the surface area is awkward to hold in your head. scroll() and view() take different arguments. animation-range-start: entry 20% and animation-range-end: contain 60% are hard to picture without seeing them. Named timelines add scroll-timeline-name, view-timeline-name and timeline-scope, each with its own rule about which element declares what. And every real deployment needs an @supports guard plus a prefers-reduced-motion fallback that people routinely forget.
This tool lets you fiddle with the knobs, see the result immediately on real scrolling elements, and walk away with CSS that already has the guards in it.
- Every timeline type — anonymous
scroll(), anonymousview(), and namedscroll-timeline/view-timelinewith optionaltimeline-scope. - Full range control — the real named ranges (
normal,cover,contain,entry,exit,entry-crossing,exit-crossing) with percentage offsets for view timelines, andnormal/ length / percentage forms for scroll timelines. - A keyframe editor — add, remove and reorder stops; set
opacity,translateX/translateY,scale,rotateandblurper stop, plus a free-form declaration escape hatch for anything else. - Easing —
linear, the standard keywords,steps()with all four jump terms, and customcubic-bezier(). - A live preview that is not a simulation — the generated CSS is injected into a real
<style>element and applied to real scrolling elements, across four subject layouts. - A scrub slider — drive progress by hand when you do not want to scroll. It uses the Web Animations API, so it works even where scroll-driven animations are unsupported.
- Clean output — commented CSS with an
@supportsguard and aprefers-reduced-motionblock, plus matching HTML. - Shareable permalinks — the whole configuration lives in the URL hash.
- Six presets to start from, and graceful behaviour on browsers that lack support.
This project is not published to npm or any other registry. It is a static folder of HTML, CSS and ES modules — clone it and serve it.
git clone https://github.com/css-scroll-driven/scroll-timeline-builder.git
cd scroll-timeline-builder
# any static server will do — pick one:
npx serve .
python3 -m http.server 8000Then open the address the server prints. A static server is required rather than opening index.html from the filesystem, because ES modules are blocked under the file:// origin by the browser's CORS rules.
There is no build step, no bundler, no node_modules, and nothing to install. Node is only needed if you want to run the tests.
The whole app is a one-way data flow around a single plain state object:
user input ──▶ store.patch() ──▶ subscribers ──┬──▶ controls.render() (form reflects state)
├──▶ keyframes.render() (stop editor)
├──▶ preview.render() (inject CSS, build stage)
├──▶ output.render() (highlighted code panels)
└──▶ syncHash() (URL permalink)
Three properties keep it honest:
- State is plain JSON. No classes, no getters, no live references to DOM nodes. That is what makes serialising it into a URL trivial and what makes
sanitizeState()able to repair anything hostile that arrives from a pasted link. codegen.jsis pure. Every export takes state and returns a string. It never touchesdocument, which is why the test suite can run the real generator undernode --testwith no DOM shim at all. If a test intests/codegen.test.mjsever needs a browser global, something has leaked into the wrong module.- The preview runs the real output.
preview.jscalls the samegenerateCss()the Copy button uses and writes it into a<style>element. The preview cannot drift from the generated code, because they are the same string.
| Module | Responsibility |
|---|---|
src/state.js |
State shape, defaults, the observable store, and sanitizeState() validation. |
src/codegen.js |
Pure state → CSS/HTML/WAAPI generation. No DOM access. |
src/presets.js |
The six built-in configurations, as partial states merged over the defaults. |
src/serialize.js |
State ⇄ compact base64url URL hash. |
src/ui/dom.js |
Three small DOM helpers ($, $$, el). |
src/ui/controls.js |
Binds the static form in index.html to the store, and shows/hides groups by timeline type. |
src/ui/keyframes.js |
The keyframe stop editor, with rows keyed by id so focus survives re-renders. |
src/ui/preview.js |
Injects the generated CSS, builds the stage markup, and drives the scrub slider via WAAPI. |
src/ui/output.js |
Code tabs, line-based syntax highlighting, and clipboard handling. |
src/ui/support.js |
CSS.supports() feature detection and the explanatory banner. |
src/main.js |
Wiring: creates the store, initialises each module, subscribes once. |
Implicit keyframes are filled in. If one stop sets opacity and another sets only translateY, a naive generator emits exactly that — and the browser fills the gaps from the element's base style, which usually causes a visible snap. The generator instead collects every property used by any stop and writes it on every stop, using a sensible default (opacity: 1, translate: 0, scale: 1, blur: 0) where a stop left it blank. The result interpolates smoothly.
The scrub slider does not fake anything. It builds a paused Web Animations API animation from the same keyframes and sets currentTime by hand. WAAPI animations outrank CSS animations in the cascade, so the scrubbed animation cleanly takes over and cleanly hands back when you press "Back to scrolling". This is also the fallback path on browsers with no scroll-driven animation support.
Timeline. Pick the timeline type first, because it changes which other controls apply.
scroll()— progress tracks a scroll container from its start to its end. Choose the scroller (nearest,rootorself) and the axis. Use this for anything tied to page progress: reading indicators, sticky header effects, scroll-linked backgrounds.view()— progress tracks the subject element's own journey through the scrollport. Choose the axis and optionally a view-timeline-inset, which shrinks the region the subject is measured against so an animation can finish before the element reaches the true viewport edge. Use this for reveals, parallax and anything per-element.- Named — one element declares a timeline (
scroll-timelineorview-timeline) and any subject references it by name. Tick Share viatimeline-scopewhen the subject is not a descendant of the declaring element; that putstimeline-scopeon a common ancestor so the name is visible further down the tree.
The scroll vs view timeline comparison is a good reference if you are unsure which of the first two you want, and anonymous vs named scroll timelines covers when the extra ceremony of a named timeline pays for itself.
Animation range. For view timelines, pick a named range and a percentage offset for each end. entry 0% is the moment the subject first touches the scrollport; cover 100% is the moment it has completely left. For scroll timelines, the range takes normal, a length such as 200px, or a percentage — named ranges do not apply.
Easing. Applies to the whole animation. steps() is genuinely useful here for sprite-style or snapped effects, since scroll position is continuous.
Keyframes. Each stop has a position (0–100%) and any combination of the six numeric properties. Leave a field blank to let the generator fill in the default. The free-form field at the bottom of each stop takes a raw declaration such as background: crimson or transform: scaleX(0) — use it for anything the numeric fields cannot express. The reading-progress preset does exactly that, because a bar needs to stretch on one axis only.
Preview. Four layouts exercise different situations: a single card, a row of cards, a full-bleed hero, and a progress bar pinned to the top of its scroller. Choosing an inline or x axis switches the preview scroller to horizontal scrolling. Scroll inside the stage to drive the animation, or use the scrub slider for precise control.
Output. The CSS tab holds the full stylesheet; the HTML tab holds markup matching the classes it targets. Both have a Copy button. "Copy share link" puts the current configuration's permalink on your clipboard.
Here is the exact output of the Reveal on enter preset:
/* Generated by scroll-timeline-builder. */
/* Timeline: anonymous view(block) */
@keyframes sdb-reveal {
0% {
opacity: 0;
transform: translate(0px, 40px);
}
100% {
opacity: 1;
transform: translate(0px, 0px);
}
}
/* Only opt in when the browser really supports scroll-driven animations —
otherwise the animation would run once on the document timeline. */
@supports (animation-timeline: view()) {
.sdb-subject {
animation: sdb-reveal auto cubic-bezier(0.22, 1, 0.36, 1) both;
animation-timeline: view(block);
animation-range-start: entry 0%;
animation-range-end: cover 40%;
}
}
/* Motion-sensitive visitors get the animation’s end state, not the motion. */
@media (prefers-reduced-motion: reduce) {
.sdb-subject {
animation: none;
}
}Three things are deliberate:
animation: … auto …— the duration isauto, which is what hands control to the timeline instead of a clock.- The
@supportsguard is not optional. Without it, a browser that does not understandanimation-timelineignores that one declaration but still honours theanimationshorthand, so the animation plays once on page load at whatever duration it inferred. The guard means unsupporting browsers simply get the unanimated element. prefers-reduced-motionsetsanimation: none, which leaves the element in its natural, un-transformed state rather than stranded at 0 opacity.
The matching markup:
<div class="sdb-scroller">
<article class="sdb-subject">Card 1</article>
<article class="sdb-subject">Card 2</article>
<article class="sdb-subject">Card 3</article>
</div>| Preset | Timeline | What it demonstrates |
|---|---|---|
| Reveal on enter | view(block) |
Fade-and-rise as elements enter the scrollport — the most common scroll-driven effect. |
| Parallax hero | view(block), full cover range |
An over-sized image drifting slower than the page around it. |
| Reading progress bar | scroll(nearest block) |
A bar filling with document scroll, using the escape hatch for scaleX. |
| Sticky header shrink | scroll() with a 200px range |
An animation confined to the first slice of scroll rather than the whole page. |
| Horizontal gallery | view(inline) with insets |
An inline-axis timeline, and symmetric view-timeline-inset. |
| Staggered card cascade | named view-timeline + timeline-scope |
Named timelines, scope sharing, and a three-stop blur-in. |
Two of these have longer write-ups worth reading: pure-CSS parallax and building a reading progress bar with scroll-timeline.
The entire builder state is serialised into the URL hash, so any configuration is a link. The hash is rewritten (via history.replaceState, debounced, so the back button stays useful) on every change:
https://example.com/scroll-timeline-builder/#c=eyJtIjoidmlldyIsIm5rIjoidmlldyIsInRuIjoiLS1yZXZlYWwiLCJ0cy…
The format is #c=<base64url(JSON)>, with no = padding so the link survives being pasted into chat clients and Markdown. Before encoding, the state is compacted: long keys become short ones (animationName → an, timelineName → tn), and each keyframe stop becomes a fixed-order tuple with trailing nulls dropped. That roughly halves the hash compared to stringifying the state as-is.
Decoding is defensive. Anything that fails to parse returns null and the app falls back to a preset, and anything that parses but contains nonsense is repaired field by field by sanitizeState() — unknown enum values snap back to defaults, and custom identifiers are filtered to a CSS-safe character set so a crafted link cannot inject declarations into the generated output.
Pasting a different hash into the address bar swaps the configuration live, via a hashchange listener.
Scroll-driven animations are supported in Chromium-based browsers (Chrome and Edge 115+), Safari 26+, and Firefox 144+. The builder itself runs anywhere that supports ES modules.
On a browser without support, the app detects it with CSS.supports('animation-timeline', 'scroll()') and CSS.supports('animation-timeline', 'view()') separately, shows a banner saying which half is missing, and stays fully usable: the generated CSS is still correct, and the scrub slider still previews the animation through the Web Animations API. Only the scroll-linked preview is unavailable.
For the wider question of how to ship these effects to a mixed audience, see browser support and progressive enhancement.
- Keyboard first. Every control is a native form element, so the whole builder is reachable and operable by keyboard with no custom key handling. The code panel's tabs implement the ARIA tabs pattern including left/right arrow navigation, and a skip link jumps straight to the controls.
- Visible focus. A single
:focus-visibleoutline is applied globally and never removed. - The app respects its own advice.
styles/app.csshas aprefers-reduced-motion: reduceblock that neutralises the app's own transitions, and the generated CSS always ships the equivalent block for your site. - Colour scheme. Light and dark are both first-class via
prefers-color-scheme, driven by custom properties rather than duplicated rules. - Live regions. The support banner is a
role="status", and copy confirmations announce through anaria-livetoast. - Labelling. Every keyframe field carries an
aria-label, because the grid is too dense for visible labels on narrow screens; the preview's scroll area is labelled and focusable so it can be scrolled with the keyboard.
There is no build step. Edit a file, reload the page.
The test suite covers the pure generation layer — timeline values, ranges, easing, keyframe emission, the full stylesheet for several states, all six presets, and the URL round trip — and runs on Node's built-in test runner with no dependencies:
node --testGuidelines if you are changing things:
- Keep
src/codegen.jspure. It is the only module the tests import directly, and its testability depends on it never touching the DOM. - Add a test alongside any change to generated output. Several tests assert an entire stylesheet string, which makes accidental formatting changes visible rather than silent.
- Route all state changes through
store.patch()so every view stays in sync. - New state fields need three things: a default in
defaultState(), validation insanitizeState(), and a mapping inserialize.js— otherwise they will not survive a permalink.
Deeper background on the API this tool generates:
- Understanding the CSS scroll-timeline API — the mental model behind timelines, ranges and progress.
- Scroll vs view timeline comparison — how to choose between the two anonymous timeline functions.
- Anonymous vs named scroll timelines — when the extra declaration is worth it.
- Sharing scroll timelines across components with timeline-scope — the rules behind the builder's scope checkbox.
- Browser support and progressive enhancement — shipping these effects safely today.
See CONTRIBUTING.md. Bug reports and preset ideas are both welcome.
MIT © 2026 css-scroll-driven
Maintained by the team behind css-scroll-driven.com.