Skip to content

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.

#320

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".

Reference #320 · Pick in the gallery

Code
svelte
<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>
#321

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".

Reference #321 · Pick in the gallery

Code
svelte
<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>
#322

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".

Reference #322 · Pick in the gallery

Code
svelte
<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>
#323

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".

Reference #323 · Pick in the gallery

Code
svelte
<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>
#324

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".

Reference #324 · Pick in the gallery

Code
svelte
<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>
#325

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".

Reference #325 · Pick in the gallery

Code
svelte
<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>
#326

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".

Reference #326 · Pick in the gallery

Code
svelte
<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>