Scroll motion
Four things that move because the page moves: a layer that lags behind its frame, a bar that measures how far you have read, a row that drifts sideways, and a deck of cards that stacks as it goes by. Where the engine has CSS scroll-driven animations the stylesheet drives all of it alone, with no listener anywhere. Where it does not, one small component writes two custom properties and the same stylesheet keeps working. Under prefers-reduced-motion: reduce the parallax, the marquee and the deck stand still.
animation-timeline is implemented in Chromium and in WebKit; Firefox does not ship it today, and there ScrollMotion takes over: it listens to scroll and resize passively, batches into requestAnimationFrame, and writes --iv-view on every .iv-parallax inside its root and --iv-scroll on the root itself. The effect is the same; the cost is a little JavaScript on one engine. Where the native timelines exist and force is off, the component sets up nothing at all and reports native: true.
Parallax
iv-parallax-frame holds the aspect ratio and clips; iv-parallax is the layer that travels, and iv-parallax--media makes an image 130 % tall so the travel never uncovers an edge. Strength is one number, --iv-parallax: 0 is fixed to the page and 1 is theatrical. Between 0.1 and 0.3 is where it reads as depth rather than as a trick.
<section id="motion-parallax" data-iv-component="scroll-motion" class="iv-stack iv-u-gap-8">
<p class="iv-u-m-0">Sample copy for the fixture. Scroll the page: every frame keeps its place in the layout while the layer inside it lags behind. Where the engine ships CSS scroll-driven animations the stylesheet drives itself from <code>animation-timeline: view()</code>; where it does not, <code>ScrollMotion</code> writes <code>--iv-view</code> and the same travel happens from JavaScript. The artwork is an inline SVG of gradients, drawn for this fixture: no photograph, no network request.</p>
<figure class="iv-stack iv-u-gap-3 iv-u-m-0">
<div class="iv-parallax-frame" style="--iv-parallax-ratio: 21 / 9">
<img class="iv-parallax iv-parallax--media" style="--iv-parallax: 0.2" width="1200" height="800" alt="Abstract ridges in green fading into a graded sky with a bright disc" src="data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%201200%20800%27%20preserveAspectRatio%3D%27xMidYMid%20slice%27%20role%3D%27img%27%3E%20%3Cdefs%3E%20%3ClinearGradient%20id%3D%27sky%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%270%27%20y2%3D%271%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%23071a14%27%2F%3E%3Cstop%20offset%3D%27.55%27%20stop-color%3D%27%230c3b2c%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%2316705031%27%2F%3E%20%3C%2FlinearGradient%3E%20%3CradialGradient%20id%3D%27sun%27%20cx%3D%27.72%27%20cy%3D%27.34%27%20r%3D%27.28%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%239bf7c8%27%2F%3E%3Cstop%20offset%3D%27.45%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%27.55%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%270%27%2F%3E%20%3C%2FradialGradient%3E%20%3ClinearGradient%20id%3D%27r1%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%271%27%20y2%3D%271%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%230f5f47%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%230a3d2e%27%2F%3E%20%3C%2FlinearGradient%3E%20%3ClinearGradient%20id%3D%27r2%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%271%27%20y2%3D%270%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%230b2c22%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%23123f31%27%2F%3E%20%3C%2FlinearGradient%3E%20%3C%2Fdefs%3E%20%3Crect%20width%3D%271200%27%20height%3D%27800%27%20fill%3D%27url%28%23sky%29%27%2F%3E%20%3Ccircle%20cx%3D%27864%27%20cy%3D%27272%27%20r%3D%27300%27%20fill%3D%27url%28%23sun%29%27%2F%3E%20%3Ccircle%20cx%3D%27864%27%20cy%3D%27272%27%20r%3D%2758%27%20fill%3D%27%23d6fde9%27%20opacity%3D%27.92%27%2F%3E%20%3Cpath%20d%3D%27M0%20470%20L180%20386%20L322%20452%20L470%20350%20L640%20458%20L812%20372%20L980%20462%20L1200%20384%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27url%28%23r1%29%27%20opacity%3D%27.92%27%2F%3E%20%3Cpath%20d%3D%27M0%20566%20L146%20508%20L300%20578%20L452%20500%20L612%20588%20L790%20512%20L962%20592%20L1200%20520%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27url%28%23r2%29%27%2F%3E%20%3Cpath%20d%3D%27M0%20690%20L220%20648%20L430%20704%20L660%20646%20L900%20710%20L1200%20654%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27%23061410%27%2F%3E%20%3C%2Fsvg%3E">
</div>
<figcaption class="iv-u-text-sm iv-u-text-muted iv-u-m-0">Default strength, <code>--iv-parallax: 0.2</code>. The layer is 130 % of the frame, so the travel never uncovers an edge.</figcaption>
</figure>
<div class="iv-stack iv-u-gap-4">
<h3 class="iv-u-m-0">Two strengths, side by side</h3>
<p class="iv-u-m-0">The left frame lags gently, the right one twice as much. Both use the same markup; only the fraction changes.</p>
</div>
<div class="iv-grid iv-u-grid-cols-1 iv-u-md-grid-cols-2 iv-u-gap-5">
<figure class="iv-stack iv-u-gap-3 iv-u-m-0">
<div class="iv-parallax-frame" style="--iv-parallax-ratio: 4 / 5">
<img class="iv-parallax iv-parallax--media" style="--iv-parallax: 0.12" width="1200" height="800" alt="Abstract blue strata crossed by two thin luminous curves" src="data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%201200%20800%27%20preserveAspectRatio%3D%27xMidYMid%20slice%27%20role%3D%27img%27%3E%20%3Cdefs%3E%20%3ClinearGradient%20id%3D%27b%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%27.35%27%20y2%3D%271%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%230a1630%27%2F%3E%3Cstop%20offset%3D%27.5%27%20stop-color%3D%27%23123a6e%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%23071a14%27%2F%3E%20%3C%2FlinearGradient%3E%20%3ClinearGradient%20id%3D%27c%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%271%27%20y2%3D%270%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%236fb3ff%27%20stop-opacity%3D%270%27%2F%3E%3Cstop%20offset%3D%27.5%27%20stop-color%3D%27%236fb3ff%27%20stop-opacity%3D%27.85%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%236fb3ff%27%20stop-opacity%3D%270%27%2F%3E%20%3C%2FlinearGradient%3E%20%3ClinearGradient%20id%3D%27d%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%271%27%20y2%3D%270%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%270%27%2F%3E%3Cstop%20offset%3D%27.42%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%27.9%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%270%27%2F%3E%20%3C%2FlinearGradient%3E%20%3C%2Fdefs%3E%20%3Crect%20width%3D%271200%27%20height%3D%27800%27%20fill%3D%27url%28%23b%29%27%2F%3E%20%3Cpath%20d%3D%27M0%20300%20C%20300%20220%20480%20380%20760%20300%20S%201050%20210%201200%20268%27%20stroke%3D%27url%28%23c%29%27%20stroke-width%3D%273%27%20fill%3D%27none%27%2F%3E%20%3Cpath%20d%3D%27M0%20372%20C%20280%20300%20520%20452%20800%20376%20S%201060%20292%201200%20344%27%20stroke%3D%27url%28%23d%29%27%20stroke-width%3D%272%27%20fill%3D%27none%27%20opacity%3D%27.8%27%2F%3E%20%3Cpath%20d%3D%27M0%20520%20C%20260%20448%20540%20600%20820%20520%20S%201080%20440%201200%20486%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27%230a2a20%27%20opacity%3D%27.9%27%2F%3E%20%3Cpath%20d%3D%27M0%20640%20C%20240%20596%20560%20704%20840%20640%20S%201090%20588%201200%20620%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27%2305100d%27%2F%3E%20%3Ccircle%20cx%3D%27250%27%20cy%3D%27188%27%20r%3D%2796%27%20fill%3D%27%236fb3ff%27%20opacity%3D%27.16%27%2F%3E%20%3Ccircle%20cx%3D%27250%27%20cy%3D%27188%27%20r%3D%2730%27%20fill%3D%27%23cfe6ff%27%20opacity%3D%27.75%27%2F%3E%20%3C%2Fsvg%3E">
</div>
<figcaption class="iv-u-text-sm iv-u-text-muted iv-u-m-0"><code>--iv-parallax: 0.12</code></figcaption>
</figure>
<figure class="iv-stack iv-u-gap-3 iv-u-m-0">
<div class="iv-parallax-frame" style="--iv-parallax-ratio: 4 / 5">
<img class="iv-parallax iv-parallax--media" style="--iv-parallax: 0.28" width="1200" height="800" alt="Abstract basin of green light under a dark sky" src="data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%201200%20800%27%20preserveAspectRatio%3D%27xMidYMid%20slice%27%20role%3D%27img%27%3E%20%3Cdefs%3E%20%3CradialGradient%20id%3D%27g1%27%20cx%3D%27.2%27%20cy%3D%27.1%27%20r%3D%27.9%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%231d5fb8%27%2F%3E%3Cstop%20offset%3D%27.55%27%20stop-color%3D%27%230b2f24%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%23050d0b%27%2F%3E%20%3C%2FradialGradient%3E%20%3ClinearGradient%20id%3D%27g2%27%20x1%3D%270%27%20y1%3D%271%27%20x2%3D%271%27%20y2%3D%270%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%230a6a43%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%27.55%27%2F%3E%20%3C%2FlinearGradient%3E%20%3C%2Fdefs%3E%20%3Crect%20width%3D%271200%27%20height%3D%27800%27%20fill%3D%27url%28%23g1%29%27%2F%3E%20%3Cellipse%20cx%3D%27620%27%20cy%3D%27700%27%20rx%3D%27760%27%20ry%3D%27240%27%20fill%3D%27url%28%23g2%29%27%20opacity%3D%27.55%27%2F%3E%20%3Cellipse%20cx%3D%27620%27%20cy%3D%27760%27%20rx%3D%27560%27%20ry%3D%27180%27%20fill%3D%27%23061512%27%20opacity%3D%27.85%27%2F%3E%20%3Cpath%20d%3D%27M0%20430%20L210%20330%20L360%20430%20L520%20300%20L700%20442%20L880%20330%20L1060%20448%20L1200%20360%20L1200%20620%20L0%20620%20Z%27%20fill%3D%27%2308251c%27%20opacity%3D%27.85%27%2F%3E%20%3Cpath%20d%3D%27M120%20250%20q%20180%20-90%20360%200%20t%20360%200%27%20stroke%3D%27%239bf7c8%27%20stroke-width%3D%272%27%20fill%3D%27none%27%20opacity%3D%27.35%27%2F%3E%20%3C%2Fsvg%3E">
</div>
<figcaption class="iv-u-text-sm iv-u-text-muted iv-u-m-0"><code>--iv-parallax: 0.28</code></figcaption>
</figure>
</div>
<div class="iv-stack iv-u-gap-4 iv-u-py-12">
<h3 class="iv-u-m-0">Filler, so there is something to scroll</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">This block exists only to give the page height. Sample copy for the fixture: a parallax that never leaves the first screen cannot be judged, and the test below measures the transform of a frame before and after scrolling.</p>
<p class="iv-u-m-0 iv-u-max-w-measure">Under <code>prefers-reduced-motion: reduce</code> every layer sits still at its authored position and the component writes nothing at all.</p>
</div>
</section>
<section id="motion-parallax-forced" data-iv-component="scroll-motion" data-iv-force="true" class="iv-stack iv-u-gap-5 iv-u-pt-12">
<h3 class="iv-u-m-0">The same frame, forced onto the JavaScript path</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">This container carries <code>data-iv-force="true"</code>, so <code>ScrollMotion</code> writes <code>--iv-view</code> even on an engine that has scroll-driven animations. It is how the fallback is checked without a second browser.</p>
<figure class="iv-stack iv-u-gap-3 iv-u-m-0">
<div class="iv-parallax-frame" style="--iv-parallax-ratio: 21 / 9">
<img class="iv-parallax iv-parallax--media" style="--iv-parallax: 0.2" width="1200" height="800" alt="Abstract ridges in green fading into a graded sky with a bright disc" src="data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%201200%20800%27%20preserveAspectRatio%3D%27xMidYMid%20slice%27%20role%3D%27img%27%3E%20%3Cdefs%3E%20%3ClinearGradient%20id%3D%27sky%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%270%27%20y2%3D%271%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%23071a14%27%2F%3E%3Cstop%20offset%3D%27.55%27%20stop-color%3D%27%230c3b2c%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%2316705031%27%2F%3E%20%3C%2FlinearGradient%3E%20%3CradialGradient%20id%3D%27sun%27%20cx%3D%27.72%27%20cy%3D%27.34%27%20r%3D%27.28%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%239bf7c8%27%2F%3E%3Cstop%20offset%3D%27.45%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%27.55%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%2329f59a%27%20stop-opacity%3D%270%27%2F%3E%20%3C%2FradialGradient%3E%20%3ClinearGradient%20id%3D%27r1%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%271%27%20y2%3D%271%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%230f5f47%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%230a3d2e%27%2F%3E%20%3C%2FlinearGradient%3E%20%3ClinearGradient%20id%3D%27r2%27%20x1%3D%270%27%20y1%3D%270%27%20x2%3D%271%27%20y2%3D%270%27%3E%20%3Cstop%20offset%3D%270%27%20stop-color%3D%27%230b2c22%27%2F%3E%3Cstop%20offset%3D%271%27%20stop-color%3D%27%23123f31%27%2F%3E%20%3C%2FlinearGradient%3E%20%3C%2Fdefs%3E%20%3Crect%20width%3D%271200%27%20height%3D%27800%27%20fill%3D%27url%28%23sky%29%27%2F%3E%20%3Ccircle%20cx%3D%27864%27%20cy%3D%27272%27%20r%3D%27300%27%20fill%3D%27url%28%23sun%29%27%2F%3E%20%3Ccircle%20cx%3D%27864%27%20cy%3D%27272%27%20r%3D%2758%27%20fill%3D%27%23d6fde9%27%20opacity%3D%27.92%27%2F%3E%20%3Cpath%20d%3D%27M0%20470%20L180%20386%20L322%20452%20L470%20350%20L640%20458%20L812%20372%20L980%20462%20L1200%20384%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27url%28%23r1%29%27%20opacity%3D%27.92%27%2F%3E%20%3Cpath%20d%3D%27M0%20566%20L146%20508%20L300%20578%20L452%20500%20L612%20588%20L790%20512%20L962%20592%20L1200%20520%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27url%28%23r2%29%27%2F%3E%20%3Cpath%20d%3D%27M0%20690%20L220%20648%20L430%20704%20L660%20646%20L900%20710%20L1200%20654%20L1200%20800%20L0%20800%20Z%27%20fill%3D%27%23061410%27%2F%3E%20%3C%2Fsvg%3E">
</div>
<figcaption class="iv-u-text-sm iv-u-text-muted iv-u-m-0">Forced fallback: watch <code>--iv-view</code> on the image go from 0 to 1 as it crosses the viewport.</figcaption>
</figure>
<div class="iv-u-py-12">
<p class="iv-u-m-0 iv-u-max-w-measure">More filler, for the same reason as above.</p>
</div>
</section>.iv-root {
--iv-parallax-ratio: 21 / 9;
--iv-parallax: 0.2;
}// data-iv-component="scroll-motion"
{
force: true,
}The frame clips with overflow: clip, not hidden. It matters: hidden would turn the frame into a scroll container, view() would measure the layer against that container instead of against the window, and the timeline would freeze. The second section of the fixture carries data-iv-force="true", which puts the component on the JavaScript path even on an engine that has the native one — that is how the fallback is checked without a second browser.
Reading progress
iv-scroll-progress is a bar pinned to the top of the window, painted once at full width and scaled horizontally, so a single composited property changes as you read. It is aria-hidden="true": the reading position already reaches assistive technology through the scrollbar and the document structure, and a second announcement would be noise.
<section id="motion-progress" data-iv-component="scroll-motion" class="iv-stack iv-u-gap-6">
<div class="iv-scroll-progress" aria-hidden="true"></div>
<p class="iv-u-m-0 iv-u-max-w-measure">Sample copy for the fixture. The bar pinned to the top of the window is <code>.iv-scroll-progress</code>: it is painted full width once and scaled horizontally, so a single composited property changes as the page moves. On an engine with <code>animation-timeline: scroll(root)</code> the stylesheet drives it alone; elsewhere <code>ScrollMotion</code> writes <code>--iv-scroll</code> on this section and the bar inherits it.</p>
<p class="iv-u-m-0 iv-u-max-w-measure">The bar is decoration and says so: <code>aria-hidden="true"</code>. The reading position is already available to assistive technology through the scrollbar and the document structure, and a second announcement would only be noise.</p>
<div class="iv-card">
<div class="iv-card__body iv-stack iv-u-gap-3">
<h3 class="iv-card__title iv-u-m-0">What to watch</h3>
<p class="iv-u-m-0">Scroll to the end of this fixture: the bar reaches the full width of the window exactly when the document does. Halfway down it covers half the window.</p>
</div>
</div>
<h3 class="iv-u-m-0 iv-u-mt-10">A long enough page</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Everything below is filler, written so the document is taller than the window and the bar has somewhere to travel.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 1. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 2. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 3. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 4. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 5. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 6. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 7. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 8. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 9. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-max-w-measure iv-u-py-6">Filler paragraph 10. Sample copy for the fixture; it carries no product claim and no measurement. It is here to make the page scroll.</p>
<p class="iv-u-m-0 iv-u-text-muted iv-u-py-12">End of the fixture. Under <code>prefers-reduced-motion: reduce</code> the bar is a still rule across the top and nothing is written from JavaScript.</p>
</section>Scope. On the fallback path --iv-scroll is written on the root of the scroll-motion container, so a bar that is not inside that root never inherits it. Put the bar in the container you marked, or mark the container that already holds it.
Reduced motion. The progress bar keeps following the page even when motion is reduced, because it reports state rather than decorating it — the same argument as a header that hides on scroll. Parallax, marquee and deck do stop.
Marquee
Pure CSS, and the author owns the duplication: write the items once, write them again with aria-hidden="true", and a translation of −50 % loops seamlessly. It pauses under the pointer and while anything inside holds focus. Under reduced motion it stops and becomes a scrollable row — which is why the window is served with role="group", tabindex="0" and a label: a scroll region has to be reachable from the keyboard.
<section id="motion-marquee" class="iv-stack iv-u-gap-10">
<p class="iv-u-m-0 iv-u-max-w-measure">Sample copy for the fixture. A marquee is pure CSS: the author writes the items twice and hides the copy from assistive technology, so the track is exactly two loops wide and a translation of −50 % is seamless. It pauses under the pointer and while anything inside it holds focus, and under <code>prefers-reduced-motion: reduce</code> it stops and becomes a scrollable row — which is why the author serves <code>tabindex="0"</code>, a role and a label on the window, exactly as the horizontal timeline does. The names below are real tokens of this framework.</p>
<div class="iv-marquee iv-marquee--fade" role="group" tabindex="0" aria-label="Design tokens" style="--iv-marquee-duration: 24s">
<div class="iv-marquee__track">
<span class="iv-marquee__item iv-badge"><code>--iv-color-primary</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-color-accent</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-space-6</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-radius-lg</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-shadow-ambient</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-glow-primary</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-focus-halo</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-tracking-tight</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-motion-slow</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-ease-out</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-color-primary</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-color-accent</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-space-6</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-radius-lg</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-shadow-ambient</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-glow-primary</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-focus-halo</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-tracking-tight</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-motion-slow</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-ease-out</code></span>
</div>
</div>
<div class="iv-stack iv-u-gap-4">
<h3 class="iv-u-m-0">Reversed, and in display type</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Same markup, <code>--reverse</code> added and a slower duration. The separators are decoration and are hidden from assistive technology.</p>
</div>
<div class="iv-marquee iv-marquee--reverse iv-marquee--fade" role="group" tabindex="0" aria-label="Parts of the framework" style="--iv-marquee-duration: 36s; --iv-marquee-gap: var(--iv-space-4)">
<div class="iv-marquee__track">
<span class="iv-marquee__item" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)">Tokens</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item iv-gradient-text" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)">Layers</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)">Fixtures</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item iv-gradient-text" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)">Contracts</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)">Utilities</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item iv-gradient-text" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)">Components</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">Tokens</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item iv-gradient-text" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">Layers</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">Fixtures</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item iv-gradient-text" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">Contracts</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">Utilities</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
<span class="iv-marquee__item iv-gradient-text" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">Components</span>
<span class="iv-marquee__item iv-u-text-muted" style="font-size: var(--iv-text-3xl); letter-spacing: var(--iv-tracking-tight)" aria-hidden="true">/</span>
</div>
</div>
<div class="iv-stack iv-u-gap-4">
<h3 class="iv-u-m-0">Vertical, inside a panel</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">The vertical variant needs a height of its own: <code>--iv-marquee-size</code> gives it one, and the edge mask turns to the block axis on its own.</p>
</div>
<div class="iv-card" style="max-inline-size: 22rem">
<div class="iv-card__body">
<div class="iv-marquee iv-marquee--vertical iv-marquee--fade" role="group" tabindex="0" aria-label="Parts of the framework, vertically" style="--iv-marquee-duration: 20s; --iv-marquee-size: 11rem; --iv-marquee-gap: var(--iv-space-4)">
<div class="iv-marquee__track">
<span class="iv-marquee__item iv-u-text-lg">Tokens</span>
<span class="iv-marquee__item iv-u-text-lg">Layers</span>
<span class="iv-marquee__item iv-u-text-lg">Fixtures</span>
<span class="iv-marquee__item iv-u-text-lg">Contracts</span>
<span class="iv-marquee__item iv-u-text-lg">Utilities</span>
<span class="iv-marquee__item iv-u-text-lg">Components</span>
<span class="iv-marquee__item iv-u-text-lg" aria-hidden="true">Tokens</span>
<span class="iv-marquee__item iv-u-text-lg" aria-hidden="true">Layers</span>
<span class="iv-marquee__item iv-u-text-lg" aria-hidden="true">Fixtures</span>
<span class="iv-marquee__item iv-u-text-lg" aria-hidden="true">Contracts</span>
<span class="iv-marquee__item iv-u-text-lg" aria-hidden="true">Utilities</span>
<span class="iv-marquee__item iv-u-text-lg" aria-hidden="true">Components</span>
</div>
</div>
</div>
</div>
<div class="iv-stack iv-u-gap-4">
<h3 class="iv-u-m-0">With a link inside</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Focus the link with the keyboard: the track stops while the focus is inside it, so a name can be read and acted on. The link points at the fixture itself and does nothing else.</p>
</div>
<div class="iv-marquee iv-marquee--fade" role="group" tabindex="0" aria-label="Design tokens, with a link" style="--iv-marquee-duration: 26s">
<div class="iv-marquee__track">
<span class="iv-marquee__item"><a href="#motion-marquee">Back to the top of this fixture</a></span>
<span class="iv-marquee__item iv-badge"><code>--iv-color-primary</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-color-accent</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-space-6</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-radius-lg</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-shadow-ambient</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-glow-primary</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-focus-halo</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-tracking-tight</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-motion-slow</code></span>
<span class="iv-marquee__item iv-badge"><code>--iv-ease-out</code></span>
<span class="iv-marquee__item" aria-hidden="true"><a href="#motion-marquee" tabindex="-1">Back to the top of this fixture</a></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-color-primary</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-color-accent</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-space-6</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-radius-lg</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-shadow-ambient</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-glow-primary</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-focus-halo</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-tracking-tight</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-motion-slow</code></span>
<span class="iv-marquee__item iv-badge" aria-hidden="true"><code>--iv-ease-out</code></span>
</div>
</div>
</section>.iv-root {
--iv-marquee-duration: 24s;
--iv-marquee-gap: var(--iv-space-4);
--iv-marquee-size: 11rem;
}Stacked cards
Each card sticks one step lower than the one before it, offset by --iv-i, so the deck reads as a deck while you scroll through it. Where the engine has scroll-driven animations the cards also shrink and dim as the deck leaves; where it does not, the sticky stack is all there is, and nothing is lost.
<section id="motion-stack" class="iv-stack iv-u-gap-8">
<p class="iv-u-m-0 iv-u-max-w-measure">Sample copy for the fixture. Each card sticks one step lower than the one before it, so the deck reads as a deck. Where the engine has scroll-driven animations each card shrinks and dims exactly while the next one rides over it (the deck serves <code>--iv-stack-count</code> and every card its <code>--iv-i</code>); where it does not, the stack is still a stack and nothing is lost. The text of the cards describes this project.</p>
<div class="iv-stack-cards" style="--iv-stack-top: var(--iv-space-6); --iv-stack-count: 4">
<section class="iv-stack-cards__card iv-card" style="--iv-i: 0">
<div class="iv-card__body iv-stack iv-u-gap-4 iv-u-py-8">
<div class="iv-cluster"><span class="iv-badge iv-badge--primary">01</span></div>
<h3 class="iv-card__title iv-u-m-0" style="font-size: var(--iv-text-2xl); letter-spacing: var(--iv-tracking-tight)">Contracts first</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Every family of this framework is written as a contract before a line of it is implemented: markup, classes, options, events, accessibility and what is out of scope.</p>
</div>
</section>
<section class="iv-stack-cards__card iv-card" style="--iv-i: 1">
<div class="iv-card__body iv-stack iv-u-gap-4 iv-u-py-8">
<div class="iv-cluster"><span class="iv-badge iv-badge--primary">02</span></div>
<h3 class="iv-card__title iv-u-m-0" style="font-size: var(--iv-text-2xl); letter-spacing: var(--iv-tracking-tight)">One grammar</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Classes start with <code>iv-</code>, custom properties with <code>--iv-</code>, attributes with <code>data-iv-</code> and events with <code>iv:</code>. Nothing else is allowed in.</p>
</div>
</section>
<section class="iv-stack-cards__card iv-card" style="--iv-i: 2">
<div class="iv-card__body iv-stack iv-u-gap-4 iv-u-py-8">
<div class="iv-cluster"><span class="iv-badge iv-badge--primary">03</span></div>
<h3 class="iv-card__title iv-u-m-0" style="font-size: var(--iv-text-2xl); letter-spacing: var(--iv-tracking-tight)">HTML that already works</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">The served markup is complete. JavaScript upgrades it; it never creates the thing the page is for.</p>
</div>
</section>
<section class="iv-stack-cards__card iv-card" style="--iv-i: 3">
<div class="iv-card__body iv-stack iv-u-gap-4 iv-u-py-8">
<div class="iv-cluster"><span class="iv-badge iv-badge--primary">04</span></div>
<h3 class="iv-card__title iv-u-m-0" style="font-size: var(--iv-text-2xl); letter-spacing: var(--iv-tracking-tight)">Motion with an argument</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Movement is transform and opacity, it has a reason, and <code>prefers-reduced-motion</code> removes all of it while leaving the composition whole.</p>
</div>
</section>
</div>
<div class="iv-stack iv-u-gap-4 iv-u-py-12" style="min-block-size: 120vh">
<h3 class="iv-u-m-0">After the deck</h3>
<p class="iv-u-m-0 iv-u-max-w-measure">Filler, so the last card can leave the top of the window and the whole deck can be scrolled past: the shrink of a card only runs while it is on its way out.</p>
<p class="iv-u-m-0 iv-u-max-w-measure">Under <code>prefers-reduced-motion: reduce</code> the cards keep their sticky offsets and drop the scale and the fade.</p>
</div>
</section>.iv-root {
--iv-stack-top: var(--iv-space-6);
--iv-stack-count: 4;
--iv-i: 0;
}An honest note about this one: with animation-range: exit on sticky subjects, the whole deck reports almost the same progress and retreats together as it leaves the top of the window, instead of handing over card by card. That is what ships in 0.7. A relay per card — a named timeline with a range derived from --iv-i and a served --iv-stack-count — is on the list for a later cycle.
Options
| Option | Attribute | Default | Effect |
|---|---|---|---|
force | data-iv-force | false | Run the JavaScript path even where the native timelines exist. For testing the fallback. |
update() recalculates after a layout change you made yourself; destroy() removes the listeners and the properties it wrote; native is read-only and says which path is live. There are no events: nothing here is a state change a page would want to react to.
Keyboard and assistive technology
None of these four is a control. The progress bar is hidden from assistive technology; the parallax layer is an image with its own alt; the deck is a set of sections that read in document order whether they are stacked or not. The marquee is the one that needs care: under reduced motion it turns into a horizontal scroll region, so it must carry a role, a label and tabindex="0", and the duplicated half must be aria-hidden so nothing is announced twice.
Without JavaScript
The marquee and the deck are CSS from end to end and need nothing. Parallax and the progress bar are CSS too on Chromium and WebKit. On Firefox, with JavaScript off, the layer sits at its authored position and the bar stays at zero: the page is complete, the decoration is absent. Nothing that carries meaning is behind any of it.
Tokens and locals
| Local | Default | What it moves |
|---|---|---|
--iv-parallax | 0.2 | Strength of the lag, as a fraction. |
--iv-parallax-ratio | frame ratio | Aspect ratio of iv-parallax-frame. |
--iv-scroll-progress-size | 3px | Thickness of the reading bar. |
--iv-scroll-progress-color | var(--iv-color-primary) | Colour of the bar, with the primary halo behind it. |
--iv-marquee-duration | 30s | One full loop of the track. |
--iv-marquee-gap, --iv-marquee-size | spacing tokens | Space between items and height of the window. |
--iv-stack-top | var(--iv-space-8) | Where a card sticks. |
--iv-stack-step, --iv-stack-scale, --iv-stack-fade | deck defaults | Offset per card, how far the one underneath shrinks, how far it dims. |
Two properties are written from JavaScript on the fallback path and are readable from CSS: --iv-view (0 as an element enters from below, 1 as it leaves at the top) on each parallax layer, and --iv-scroll (0 to 1) on the root of the component.
Common mistakes
- A parallax layer with no
--iv-parallax--mediaheight: the travel uncovers the edge of the frame. - Giving the frame
overflow: hiddenby hand and wondering why nothing moves in Chromium. - A progress bar outside the
scroll-motionroot: it works on two engines and stays flat on the third. - A marquee whose second half is not
aria-hidden: every item is read twice. - Using the deck for steps that must be read in order and compared. That is a stepper or a timeline.
- Treating reduced motion as “switch the effects off”: the reading bar is information, and it stays.
Class index
Parts and modifiers of this family that the examples above do not show. The full list per stylesheet lives in the reference.
| Class | What it does |
|---|---|
iv-parallax__layer | Layer inside a parallax frame: it is the element that moves, while the frame clips it. |