Motion
Scroll
Scroll-linked motion with names and exact values: staggered reveal, view-timeline reveal, parallax, reading progress, horizontal scroll, pinned steps and scroll-driven counters.
Scroll motion ties an effect to where the reader is on the page. Every demo below scrolls inside its own container, so it works in a card, a gallery tile or a modal, and the stage scrolls it for you while it is in view. Scroll it yourself (wheel, trackpad or touch) to take over; the Pause button stops the autoplay.
Where the browser supports CSS scroll-driven animations (animation-timeline: scroll() and view()), the effect runs on the compositor with no JavaScript. Where it does not, a passive scroll listener writes the same progress to a custom property and the same transforms read it. Under prefers-reduced-motion the container shows its final state and does not scroll by itself.
Staggered reveal on scroll
Each row fades in and rises 24px as it enters the container; rows arriving together are 80ms apart. It fires once per row (IntersectionObserver, threshold 0.35). Ask for "a staggered reveal on scroll" or "Use #320".
Payout sent
$12,480.00 to Northwind Ltd 09:41
Invoice #4821 paid
Acme Studio, net 30 09:12
New subscriber
Growth plan, annual 08:57
Refund issued
$89.00 to J. Moreira 08:30
Dispute won
Case 7731, funds released Yesterday
Payout scheduled
$9,210.40 on Friday Yesterday
Reference #320 · Pick in the gallery
Code
<script lang="ts">
import { Card, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
const rows = [
{ title: 'Payout sent', meta: '$12,480.00 to Northwind Ltd', when: '09:41' },
{ title: 'Invoice #4821 paid', meta: 'Acme Studio, net 30', when: '09:12' },
{ title: 'New subscriber', meta: 'Growth plan, annual', when: '08:57' },
{ title: 'Refund issued', meta: '$89.00 to J. Moreira', when: '08:30' },
{ title: 'Dispute won', meta: 'Case 7731, funds released', when: 'Yesterday' },
{ title: 'Payout scheduled', meta: '$9,210.40 on Friday', when: 'Yesterday' }
];
let shown = $state(new Set<number>());
// IntersectionObserver rooted in the scroller: a row is revealed once, when 35% of it is visible.
const reveal: Attachment<HTMLElement> = (root) => {
const io = new IntersectionObserver(
(entries) => {
const hits = entries
.filter((e) => e.isIntersecting)
.map((e) => Number((e.target as HTMLElement).dataset.i));
if (hits.length) shown = new Set([...shown, ...hits]);
},
{ root, threshold: 0.35 }
);
root.querySelectorAll('[data-i]').forEach((el) => io.observe(el));
// Back at the top the list is "unread" again: scrolling down replays the reveal for the user.
const rewind = () => {
if (root.scrollTop === 0) shown = new Set();
};
root.addEventListener('scroll', rewind, { passive: true });
return () => {
io.disconnect();
root.removeEventListener('scroll', rewind);
};
};
// Autoplay = scroll the container down in 2400ms (ease-in-out) once per cycle; wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
shown = new Set();
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 2400);
el.scrollTop = to * (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
</script>
<MotionStage label="Staggered reveal on scroll: rows fade in one after another as the list scrolls" loop={4600}>
{#snippet children(m)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="feed" role="region" aria-label="Activity feed" tabindex="0" {@attach reveal} {@attach drive(m)}>
{#each rows as row, i (row.title)}
<div class="feed__row" class:feed__row--in={m.reduced || shown.has(i)} data-i={i} style="--i:{i % 3}">
<Card>
<p class="feed__title">{row.title}</p>
<p class="feed__meta">{row.meta} <span class="feed__when">{row.when}</span></p>
</Card>
</div>
{/each}
</div>
{/snippet}
</MotionStage>
<style>
.feed {
display: flex;
flex-direction: column;
gap: var(--ca-space-3);
inline-size: 100%;
max-inline-size: 360px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--ca-space-2);
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
}
.feed__row {
flex: none;
opacity: 0;
transform: translateY(24px);
}
.feed__row--in {
opacity: 1;
transform: translateY(0);
transition:
opacity 420ms cubic-bezier(0.2, 0, 0, 1) calc(var(--i) * 80ms),
transform 420ms cubic-bezier(0.2, 0, 0, 1) calc(var(--i) * 80ms);
}
.feed__title { margin: 0; font-weight: var(--ca-font-weight-semibold); }
.feed__meta { margin: var(--ca-space-1) 0 0; color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); }
.feed__when { color: var(--ca-text-hint); font-family: var(--ca-font-mono); font-size: var(--ca-font-size-caption); margin-inline-start: var(--ca-space-2); }
</style>View timeline reveal
A reveal that scrubs: each tile's opacity, rise and scale follow its position while it enters, and run backwards when you scroll up. Pure CSS with `animation-timeline: view(block)` and `animation-range: entry 0% entry 70%`. Ask for "a view timeline reveal" or "Use #321".
Conversion
4.8% +0.6 pt
Avg. order
$64.20 +$3.10
Returning
37% +5 pt
Refund rate
1.2% -0.3 pt
Page speed
1.4 s -0.5 s
Reference #321 · Pick in the gallery
Code
<script lang="ts">
import { Card, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
const native = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()');
const tiles = [
{ k: 'Conversion', v: '4.8%', d: '+0.6 pt' },
{ k: 'Avg. order', v: '$64.20', d: '+$3.10' },
{ k: 'Returning', v: '37%', d: '+5 pt' },
{ k: 'Refund rate', v: '1.2%', d: '-0.3 pt' },
{ k: 'Page speed', v: '1.4 s', d: '-0.5 s' }
];
// Fallback for browsers without `animation-timeline: view()`: each tile's progress through the
// container (0 = just entering at the bottom, 1 = settled) is written to its own --v on scroll.
const fallback = (m: MotionCtx): Attachment<HTMLElement> => (root) => {
if (native || m.reduced) return;
const items = [...root.querySelectorAll<HTMLElement>('[data-tile]')];
const write = () => {
const r = root.getBoundingClientRect();
for (const el of items) {
const t = el.getBoundingClientRect();
const v = (r.bottom - t.top) / (t.height * 1.6);
el.style.setProperty('--v', String(Math.min(1, Math.max(0, v))));
}
};
write();
root.addEventListener('scroll', write, { passive: true });
return () => root.removeEventListener('scroll', write);
};
// Autoplay = scroll down in 2800ms (ease-in-out) once per cycle; wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 2800);
el.scrollTop = to * (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
</script>
<MotionStage label="View timeline reveal: each tile fades and scales in as it enters the scroll area" loop={4600}>
{#snippet children(m)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="vt"
class:vt--native={native}
class:vt--final={m.reduced}
role="region"
aria-label="Store metrics"
tabindex="0"
{@attach fallback(m)}
{@attach drive(m)}
>
{#each tiles as t (t.k)}
<div class="vt__tile" data-tile>
<Card>
<p class="vt__k">{t.k}</p>
<p class="vt__v">{t.v} <span class="vt__d">{t.d}</span></p>
</Card>
</div>
{/each}
</div>
{/snippet}
</MotionStage>
<style>
.vt {
display: flex;
flex-direction: column;
gap: var(--ca-space-3);
inline-size: 100%;
max-inline-size: 360px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--ca-space-2);
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
}
.vt__tile {
--v: 1;
flex: none;
opacity: var(--v);
transform: translateY(calc((1 - var(--v)) * 28px)) scale(calc(0.92 + var(--v) * 0.08));
}
.vt--final .vt__tile { opacity: 1; transform: none; }
.vt__k { margin: 0; font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); letter-spacing: 0.06em; text-transform: uppercase; }
.vt__v { margin: var(--ca-space-1) 0 0; font-size: var(--ca-font-size-h6); font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-primary); font-variant-numeric: tabular-nums; }
.vt__d { margin-inline-start: var(--ca-space-2); font-size: var(--ca-font-size-caption); color: var(--ca-semantic-success); }
@supports (animation-timeline: view()) {
.vt--native:not(.vt--final) .vt__tile {
animation-name: view-in;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: 1ms;
animation-timeline: view(block);
animation-range: entry 0% entry 70%;
}
@keyframes view-in {
from { opacity: 0; transform: translateY(28px) scale(0.92); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
}
</style>Parallax
Sky, ridge, foreground and caption travel -6%, -22%, -48% and +30% of the frame over the same scroll distance, so near things move faster than far things. Use it behind hero headlines. Ask for "a parallax" or "Use #322".
Route 14
Col de la Croix
2,067 m · 18.4 km · 7.1% average
Reference #322 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
// Scroll progress 0..1 of the container. With CSS scroll-driven animations the browser computes it;
// elsewhere the scroll listener writes it to --p and the same transforms read it.
const native = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: scroll()');
const track = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
if (native) return;
const write = () => {
const max = el.scrollHeight - el.clientHeight;
if (!m.reduced) el.style.setProperty('--p', String(max > 0 ? el.scrollTop / max : 0));
};
write();
el.addEventListener('scroll', write, { passive: true });
return () => el.removeEventListener('scroll', write);
};
// Autoplay = scroll down in 3000ms, once per cycle; wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 3000);
el.scrollTop = to * (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
</script>
<MotionStage label="Parallax layers: sky, ridge and foreground move at different speeds while scrolling" loop={6200}>
{#snippet children(m)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="scene"
class:scene--native={native}
class:scene--final={m.reduced}
role="region"
aria-label="Alpine route preview"
tabindex="0"
{@attach track(m)}
{@attach drive(m)}
>
<div class="scene__sticky">
<div class="layer layer--sky" aria-hidden="true"></div>
<svg class="layer layer--ridge" viewBox="0 0 400 140" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 140V80l60-40 50 30 70-55 80 60 60-35 80 50v50z" />
</svg>
<svg class="layer layer--near" viewBox="0 0 400 120" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 120V70l80-28 90 40 90-46 140 52v32z" />
</svg>
<div class="layer layer--copy">
<p class="copy__eyebrow">Route 14</p>
<p class="copy__title">Col de la Croix</p>
<p class="copy__meta">2,067 m · 18.4 km · 7.1% average</p>
</div>
</div>
<div class="scene__track" aria-hidden="true"></div>
</div>
{/snippet}
</MotionStage>
<style>
.scene {
--p: 0;
inline-size: 100%;
max-inline-size: 360px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
scroll-timeline: --scene block;
}
.scene--final { --p: 1; }
.scene__sticky {
position: sticky;
inset-block-start: 0;
block-size: 13rem;
overflow: hidden;
contain: paint;
}
.scene__track { block-size: 26rem; }
.layer { position: absolute; inset: 0; }
.layer--sky {
background: linear-gradient(to bottom, var(--ca-brand-main), var(--ca-surface-raised));
opacity: 0.55;
transform: translateY(calc(var(--p) * -6%));
}
.layer--ridge {
inset-block-start: auto;
block-size: 55%;
inline-size: 100%;
fill: var(--ca-brand-text);
opacity: 0.35;
transform: translateY(calc(var(--p) * -22%));
}
.layer--near {
inset-block-start: auto;
block-size: 42%;
inline-size: 100%;
fill: var(--ca-text-primary);
opacity: 0.8;
transform: translateY(calc(var(--p) * -48%));
}
.layer--copy {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: var(--ca-space-1);
padding: var(--ca-space-4);
transform: translateY(calc(var(--p) * 30%));
opacity: calc(1 - var(--p) * 0.85);
}
.copy__eyebrow { margin: 0; font-family: var(--ca-font-mono); font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); letter-spacing: 0.08em; text-transform: uppercase; }
.copy__title { margin: 0; font-size: var(--ca-font-size-h5); font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-primary); }
.copy__meta { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
@supports (animation-timeline: scroll()) {
.scene--native:not(.scene--final) .layer {
animation-name: parallax;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: 1ms;
animation-timeline: --scene;
}
.scene--native:not(.scene--final) .layer--sky { --to: -6%; }
.scene--native:not(.scene--final) .layer--ridge { --to: -22%; }
.scene--native:not(.scene--final) .layer--near { --to: -48%; }
.scene--native:not(.scene--final) .layer--copy { --to: 30%; animation-name: parallax-copy; }
@keyframes parallax {
from { transform: translateY(0); }
to { transform: translateY(var(--to)); }
}
@keyframes parallax-copy {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(30%); opacity: 0.15; }
}
}
</style>Reading progress bar
A 4px bar fills from the left as the article scrolls, with a percentage readout. It is tied to the scroll position, so it is exact and reversible. Ask for "a reading progress bar" or "Use #323".
Engineering · 6 min read 0%
How we cut checkout time in half
We measured every step of the flow before touching a pixel. Address entry alone took 41 seconds on a phone.
The first change was removing the account wall: guests now pay in three taps, and the median dropped to 58 seconds.
Next we prefilled the postcode from the card, replaced the select lists with native pickers and moved totals above the fold.
Failed payments were the last big leak. A retry sheet with the previous method preselected recovered 23% of them.
Together, the changes took the median from 2m 04s to 58s, and conversion rose 11 points in four weeks.
Reference #323 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
const native = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: scroll()');
let pct = $state(0);
// Fallback and label: the same progress the CSS timeline uses, as 0..1 in --p and 0..100 in the readout.
const track = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const write = () => {
const max = el.scrollHeight - el.clientHeight;
const p = max > 0 ? el.scrollTop / max : 0;
pct = Math.round(p * 100);
if (!native && !m.reduced) el.style.setProperty('--p', String(p));
};
write();
el.addEventListener('scroll', write, { passive: true });
return () => el.removeEventListener('scroll', write);
};
// Autoplay = read the article top to bottom in 3600ms (linear, like a steady reader); wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 3600);
el.scrollTop = to * t;
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
</script>
<MotionStage label="Reading progress bar: a bar at the top fills as the article scrolls" loop={5200}>
{#snippet children(m)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="doc"
class:doc--native={native}
class:doc--final={m.reduced}
role="region"
aria-label="Article: How we cut checkout time in half"
tabindex="0"
{@attach track(m)}
{@attach drive(m)}
>
<div class="doc__bar" aria-hidden="true"><span class="doc__fill"></span></div>
<article class="doc__body">
<p class="doc__meta">Engineering · 6 min read <span class="doc__pct">{m.reduced ? 100 : pct}%</span></p>
<h3 class="doc__title">How we cut checkout time in half</h3>
<p>We measured every step of the flow before touching a pixel. Address entry alone took 41 seconds on a phone.</p>
<p>The first change was removing the account wall: guests now pay in three taps, and the median dropped to 58 seconds.</p>
<p>Next we prefilled the postcode from the card, replaced the select lists with native pickers and moved totals above the fold.</p>
<p>Failed payments were the last big leak. A retry sheet with the previous method preselected recovered 23% of them.</p>
<p>Together, the changes took the median from 2m 04s to 58s, and conversion rose 11 points in four weeks.</p>
</article>
</div>
{/snippet}
</MotionStage>
<style>
.doc {
--p: 0;
position: relative;
inline-size: 100%;
max-inline-size: 360px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
}
.doc--final { --p: 1; }
.doc__bar {
position: sticky;
inset-block-start: 0;
z-index: 1;
block-size: 4px;
background: var(--ca-outline-subtle);
}
.doc__fill {
display: block;
block-size: 100%;
background: var(--ca-brand-main);
transform-origin: left center;
transform: scaleX(var(--p));
}
.doc__body { padding: var(--ca-space-3) var(--ca-space-4) var(--ca-space-5); }
.doc__body p { margin: 0 0 var(--ca-space-3); color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); line-height: 1.55; }
.doc__meta { display: flex; justify-content: space-between; font-family: var(--ca-font-mono); font-size: var(--ca-font-size-caption); color: var(--ca-text-hint); }
.doc__pct { color: var(--ca-brand-text); }
.doc__title { margin: 0 0 var(--ca-space-3); font-size: var(--ca-font-size-h6); color: var(--ca-text-primary); }
@supports (animation-timeline: scroll()) {
.doc--native:not(.doc--final) .doc__fill {
animation-name: fill;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: 1ms;
animation-timeline: scroll(nearest block);
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
</style>Horizontal scroll section
Scrolling down inside a pinned frame slides four cards sideways (-75% of a 400% rail). Use it for steps that read left to right. Ask for "a horizontal scroll section" or "Use #324".
- 01
Capture
Scan a receipt, the amount and merchant fill in.
- 02
Categorize
Rules sort it into Travel, Meals or Software.
- 03
Approve
Managers approve from the lock screen.
- 04
Reimburse
Paid out on the next Friday run.
Reference #324 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
const native = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: scroll()');
const slides = [
{ n: '01', title: 'Capture', body: 'Scan a receipt, the amount and merchant fill in.' },
{ n: '02', title: 'Categorize', body: 'Rules sort it into Travel, Meals or Software.' },
{ n: '03', title: 'Approve', body: 'Managers approve from the lock screen.' },
{ n: '04', title: 'Reimburse', body: 'Paid out on the next Friday run.' }
];
// Fallback: write vertical progress (0..1) to --p; the track translates by -p * (slides - 1) * 100% of a slide.
const track = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
if (native) return;
const write = () => {
const max = el.scrollHeight - el.clientHeight;
if (!m.reduced) el.style.setProperty('--p', String(max > 0 ? el.scrollTop / max : 0));
};
write();
el.addEventListener('scroll', write, { passive: true });
return () => el.removeEventListener('scroll', write);
};
// Autoplay = scroll down in 3200ms (ease-in-out), which slides the track sideways; wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 3200);
el.scrollTop = to * (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
</script>
<MotionStage label="Horizontal scroll section: scrolling down slides four cards sideways" loop={5600}>
{#snippet children(m)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="hs"
class:hs--native={native}
class:hs--final={m.reduced}
role="region"
aria-label="Expense flow, four steps"
tabindex="0"
{@attach track(m)}
{@attach drive(m)}
>
<div class="hs__pin">
<ol class="hs__rail">
{#each slides as s (s.n)}
<li class="hs__slide">
<span class="hs__n">{s.n}</span>
<p class="hs__title">{s.title}</p>
<p class="hs__body">{s.body}</p>
</li>
{/each}
</ol>
<div class="hs__bar" aria-hidden="true"><span class="hs__fill"></span></div>
</div>
<div class="hs__track" aria-hidden="true"></div>
</div>
{/snippet}
</MotionStage>
<style>
.hs {
--p: 0;
inline-size: 100%;
max-inline-size: 360px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
scroll-timeline: --hs block;
}
.hs--final { --p: 1; }
.hs__pin {
position: sticky;
inset-block-start: 0;
/* sticky already establishes the containing block for the absolute progress bar */
block-size: 13rem;
overflow: hidden;
contain: paint;
}
.hs__track { block-size: 30rem; }
.hs__rail {
display: flex;
margin: 0;
padding: 0;
list-style: none;
inline-size: 400%;
block-size: calc(100% - 4px);
transform: translateX(calc(var(--p) * -75%));
}
.hs__slide {
flex: 0 0 25%;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--ca-space-1);
padding: var(--ca-space-4) var(--ca-space-5);
box-sizing: border-box;
}
.hs__n { font-family: var(--ca-font-mono); font-size: var(--ca-font-size-caption); color: var(--ca-brand-text); letter-spacing: 0.08em; }
.hs__title { margin: 0; font-size: var(--ca-font-size-h5); font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-primary); }
.hs__body { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
.hs__bar { position: absolute; inset-inline: 0; inset-block-end: 0; block-size: 4px; background: var(--ca-outline-subtle); }
.hs__fill { display: block; block-size: 100%; background: var(--ca-brand-main); transform-origin: left center; transform: scaleX(var(--p)); }
@supports (animation-timeline: scroll()) {
.hs--native:not(.hs--final) .hs__rail {
animation-name: slide;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: 1ms;
animation-timeline: --hs;
}
.hs--native:not(.hs--final) .hs__fill {
animation-name: fill;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: 1ms;
animation-timeline: --hs;
}
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(-75%); }
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
</style>Pinned step story
The media stays pinned and swaps its figure as each step passes the middle of the frame; inactive steps dim to 40%. Also called scrollytelling. Ask for "a pinned step story" or "Use #325".
Connect your bank
Read-only access through your bank. We never see your password.
We sort every transaction
Rules and a model tag each payment. You only review the odd ones.
See the month at a glance
Spending, recurring bills and what is left before payday.
Reference #325 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
const steps = [
{ title: 'Connect your bank', body: 'Read-only access through your bank. We never see your password.', figure: '3 min', note: 'Average setup' },
{ title: 'We sort every transaction', body: 'Rules and a model tag each payment. You only review the odd ones.', figure: '94%', note: 'Auto-categorized' },
{ title: 'See the month at a glance', body: 'Spending, recurring bills and what is left before payday.', figure: '$1,284', note: 'Left this month' }
];
let active = $state(0);
// Active step = the step block crossing the middle of the container (IntersectionObserver, root = scroller).
const watch: Attachment<HTMLElement> = (root) => {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) if (e.isIntersecting) active = Number((e.target as HTMLElement).dataset.step);
},
{ root, rootMargin: '-45% 0px -45% 0px', threshold: 0 }
);
root.querySelectorAll('[data-step]').forEach((el) => io.observe(el));
return () => io.disconnect();
};
// Autoplay = scroll down in 3600ms (ease-in-out) so each step takes the pin in turn; wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 3600);
el.scrollTop = to * (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
</script>
<MotionStage label="Pinned step story: the media stays put and changes as each step scrolls past" loop={5600}>
{#snippet children(m)}
{@const cur = m.reduced ? steps.length - 1 : active}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="story" role="region" aria-label="How it works, three steps" tabindex="0" {@attach watch} {@attach drive(m)}>
<div class="story__media" aria-hidden="true">
{#each steps as s, i (s.title)}
<div class="story__fig" class:story__fig--on={i === cur}>
<span class="story__num">{s.figure}</span>
<span class="story__note">{s.note}</span>
</div>
{/each}
<div class="story__dots">
{#each steps as s, i (s.title)}<span class="story__dot" class:story__dot--on={i === cur}></span>{/each}
</div>
</div>
<ol class="story__steps">
{#each steps as s, i (s.title)}
<li class="story__step" class:story__step--on={i === cur} data-step={i}>
<p class="story__title">{s.title}</p>
<p class="story__body">{s.body}</p>
</li>
{/each}
</ol>
</div>
{/snippet}
</MotionStage>
<style>
.story {
display: grid;
grid-template-columns: 9rem 1fr;
align-items: start;
inline-size: 100%;
max-inline-size: 420px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
}
.story__media {
position: sticky;
inset-block-start: 0;
display: grid;
place-items: center;
block-size: 13rem;
background: color-mix(in oklab, var(--ca-brand-main) 10%, transparent);
}
.story__fig {
grid-area: 1 / 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ca-space-1);
opacity: 0;
transform: translateY(12px) scale(0.96);
transition: opacity 320ms cubic-bezier(0.2, 0, 0, 1), transform 320ms cubic-bezier(0.2, 0, 0, 1);
}
.story__fig--on { opacity: 1; transform: none; }
.story__num { font-size: var(--ca-font-size-h3); font-weight: var(--ca-font-weight-semibold); color: var(--ca-brand-text); font-variant-numeric: tabular-nums; }
.story__note { font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
.story__dots { position: absolute; inset-block-end: var(--ca-space-3); display: flex; gap: var(--ca-space-1); }
.story__dot { inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--ca-outline-default); transition: background 320ms; }
.story__dot--on { background: var(--ca-brand-main); }
.story__steps { margin: 0; padding: 0; list-style: none; }
.story__step {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--ca-space-1);
block-size: 13rem;
padding: 0 var(--ca-space-4);
}
/* The step in view is the loud one. Passed and upcoming steps drop to the secondary text colour
(AA on the stage), never to a lower opacity: opacity dims text below 4.5:1. */
.story__title { margin: 0; font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-secondary); transition: color 320ms; }
.story__body { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
.story__step--on .story__title { color: var(--ca-text-primary); }
</style>Scroll-linked counter and chart draw
A revenue figure counts from $0 to $48,210 while the line draws itself, both scrubbed by scroll position. Ask for "a scroll-linked counter and chart draw" or "Use #326".
Revenue, last 30 days
$48,210 $0
+12.4% vs previous period
Reference #326 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import type { Attachment } from 'svelte/attachments';
const TOTAL = 48210;
// One polyline, normalized pathLength=1 so stroke-dashoffset 1 -> 0 draws it from start to end.
const line = 'M4 74 L36 62 L68 66 L100 44 L132 50 L164 28 L196 34 L228 12';
let p = $state(0);
const native = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: scroll()');
// The counter always reads progress from JS (text cannot be scroll-driven in CSS); the chart draw uses the
// CSS timeline when supported and --p otherwise, so both stay in step with the same scroll position.
const track = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const write = () => {
const max = el.scrollHeight - el.clientHeight;
p = max > 0 ? el.scrollTop / max : 0;
if (!native && !m.reduced) el.style.setProperty('--p', String(p));
};
write();
el.addEventListener('scroll', write, { passive: true });
return () => el.removeEventListener('scroll', write);
};
// Autoplay = scroll down in 2800ms (ease-out) once per cycle; wheel or touch = takeover.
const drive = (m: MotionCtx): Attachment<HTMLElement> => (el) => {
const stop = () => m.takeover();
el.addEventListener('wheel', stop, { passive: true });
el.addEventListener('touchmove', stop, { passive: true });
let raf = 0;
if (m.playing) {
void m.cycle;
el.scrollTop = 0;
const to = el.scrollHeight - el.clientHeight;
const t0 = performance.now();
const step = (now: number) => {
if (!m.playing) return; // taken over or paused: never write a frame after the user starts scrolling
const t = Math.min(1, (now - t0) / 2800);
el.scrollTop = to * (1 - (1 - t) ** 3);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('wheel', stop);
el.removeEventListener('touchmove', stop);
};
};
const fmt = new Intl.NumberFormat('en-US');
</script>
<MotionStage label="Scroll-linked counter and chart: the revenue number and the line grow as you scroll" loop={5000}>
{#snippet children(m)}
{@const v = m.reduced ? 1 : p}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="rev"
class:rev--native={native}
class:rev--final={m.reduced}
role="region"
aria-label="Revenue summary"
tabindex="0"
{@attach track(m)}
{@attach drive(m)}
>
<div class="rev__pin">
<p class="rev__label">Revenue, last 30 days</p>
<p class="rev__value">
<span class="ca-sr-only">${fmt.format(TOTAL)}</span>
<span aria-hidden="true">${fmt.format(Math.round(TOTAL * v))}</span>
</p>
<svg class="rev__chart" viewBox="0 0 232 80" aria-hidden="true">
<path class="rev__base" d="M4 76 H228" />
<path class="rev__line" d={line} pathLength="1" />
</svg>
<p class="rev__delta">+12.4% vs previous period</p>
</div>
<div class="rev__track" aria-hidden="true"></div>
</div>
{/snippet}
</MotionStage>
<style>
.rev {
--p: 0;
inline-size: 100%;
max-inline-size: 360px;
block-size: 13rem;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--ca-radius-lg);
box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
}
.rev--final { --p: 1; }
.rev__pin {
position: sticky;
inset-block-start: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--ca-space-1);
block-size: 13rem;
padding: 0 var(--ca-space-5);
box-sizing: border-box;
}
.rev__track { block-size: 26rem; }
.rev__label { margin: 0; font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); letter-spacing: 0.06em; text-transform: uppercase; }
.rev__value { margin: 0; font-size: var(--ca-font-size-h3); font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-primary); font-variant-numeric: tabular-nums; }
.rev__chart { inline-size: 100%; block-size: 5rem; overflow: visible; }
.rev__base { fill: none; stroke: var(--ca-outline-subtle); stroke-width: 1; }
.rev__line {
fill: none;
stroke: var(--ca-brand-main);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: calc(1 - var(--p));
}
.rev__delta { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-semantic-success); }
@supports (animation-timeline: scroll()) {
.rev--native:not(.rev--final) .rev__line {
animation-name: draw;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: 1ms;
animation-timeline: scroll(nearest block);
}
@keyframes draw {
from { stroke-dashoffset: 1; }
to { stroke-dashoffset: 0; }
}
}
</style>