20 CSS Scroll Animations13 / 20

Pure CSSMIT licensed

Scroll Timeline Axis and Horizontal Scrollers

Scroll timelines are not vertical by default so much as block by default. Switching to scroll(inline) or scroll-timeline-axis: inline binds progress to horizontal scrolling instead, which is what a filmstrip, a card rail or a table with frozen columns needs. The indicator here tracks inline progress across a nine-frame rail.

Published Updated

Live Demo
Try it

The code

<section class="sa-13" aria-labelledby="sa-13-h">
  <fieldset class="sa-13__theme">
    <legend class="sa-13__themelegend">Theme</legend>
    <input class="sa-13__themeinput" type="radio" name="sa-13-theme" id="sa-13-theme-auto" checked>
    <label class="sa-13__themeopt" for="sa-13-theme-auto">Auto</label>
    <input class="sa-13__themeinput" type="radio" name="sa-13-theme" id="sa-13-theme-light">
    <label class="sa-13__themeopt" for="sa-13-theme-light">Light</label>
    <input class="sa-13__themeinput" type="radio" name="sa-13-theme" id="sa-13-theme-dark">
    <label class="sa-13__themeopt" for="sa-13-theme-dark">Dark</label>
  </fieldset>

  <div class="sa-13__stage">
    <header class="sa-13__head">
      <p class="sa-13__eyebrow">axis: inline</p>
      <h2 class="sa-13__h" id="sa-13-h">The scrollbar that acts as the clock</h2>
      <p class="sa-13__sub">Drag the rail sideways, or focus it and use the arrow keys. Progress is read from the rail's horizontal overflow — <span class="sa-13__mono">scroll-timeline: --sa-13-rail inline</span>.</p>
    </header>

    <div class="sa-13__rail" tabindex="0" role="region" aria-label="Prism build frames, scrolls horizontally">
      <div class="sa-13__gauge">
        <p class="sa-13__glabel">inline<br>progress</p>
        <div class="sa-13__gtrack"><span class="sa-13__gfill"></span></div>
      </div>
      <ol class="sa-13__frames">
        <li class="sa-13__frame"><p class="sa-13__fnum">01</p><p class="sa-13__ft">Checkout</p><p class="sa-13__fm">prism/web · 0.9s</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">02</p><p class="sa-13__ft">Restore cache</p><p class="sa-13__fm">hit · 4.2s</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">03</p><p class="sa-13__ft">Install deps</p><p class="sa-13__fm">1,204 packages</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">04</p><p class="sa-13__ft">Typecheck</p><p class="sa-13__fm">no errors</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">05</p><p class="sa-13__ft">Unit tests</p><p class="sa-13__fm">912 passed</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">06</p><p class="sa-13__ft">Bundle</p><p class="sa-13__fm">412 kB gzip</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">07</p><p class="sa-13__ft">Visual diff</p><p class="sa-13__fm">2 to review</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">08</p><p class="sa-13__ft">Sign artifacts</p><p class="sa-13__fm">Priya Sharma</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">09</p><p class="sa-13__ft">Deploy to production</p><p class="sa-13__fm">Sable · eu-west</p></li>
      </ol>
    </div>

    <ul class="sa-13__facts">
      <li class="sa-13__fact"><span class="sa-13__fk">inline</span> resolves through writing mode. <span class="sa-13__fk">x</span> is physical and never changes.</li>
      <li class="sa-13__fact">The axis belongs to the timeline owner. Consumers only name the timeline.</li>
      <li class="sa-13__fact">No <span class="sa-13__mono">overflow-x</span> means no inline scroll to read, and no error either.</li>
    </ul>
  </div>
</section>
.sa-13 {
  --page: #000000;
  --surface: #0a0a0a;
  --ink: #f2f2f2;
  --muted: #7d7d7d;
  --rule: #1c1c1c;
  --accent: #b6ff2e;
  --page: oklch(0% 0 0);
  --surface: oklch(12% 0 0);
  --ink: oklch(96% 0 0);
  --muted: oklch(58% 0 0);
  --rule: oklch(21% 0 0);
  --accent: oklch(90% 0.22 122);
  --sa-13-frame: 11rem;
  --font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  position: relative;
  padding: 3.5rem 1rem 2.5rem;
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-display);
}

.sa-13 *,
.sa-13 *::before,
.sa-13 *::after {
  box-sizing: border-box;
}

.sa-13__stage {
  max-inline-size: 64rem;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 0.875rem;
}

.sa-13__head {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
}

.sa-13__eyebrow {
  margin: 0;
  font-size: 0.6875rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
}

.sa-13__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 3.6vw, 1.875rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1.1;
}

.sa-13__sub {
  margin: 0;
  max-inline-size: 46rem;
  font-family: system-ui, sans-serif;
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sa-13__mono {
  color: var(--accent);
}

.sa-13__rail {
  display: flex;
  align-items: stretch;
  gap: 0.5rem;
  padding: 0.5rem;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-inline: contain;
  scroll-snap-type: inline mandatory;
  scroll-timeline: --sa-13-rail inline;
  background: var(--surface);
}

.sa-13__rail:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.sa-13__gauge {
  position: sticky;
  inset-inline-start: 0;
  z-index: 2;
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 0.5rem;
  inline-size: 4.25rem;
  padding: 0.625rem 0.5rem;
  background: var(--page);
}

.sa-13__glabel {
  margin: 0;
  font-size: 0.5625rem;
  line-height: 1.3;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

.sa-13__gtrack {
  block-size: 0.375rem;
  background: var(--rule);
}

.sa-13__gfill {
  display: block;
  block-size: 100%;
  inline-size: 100%;
  background: var(--accent);
  transform-origin: left center;
  transform: scaleX(1);
}

.sa-13__frames {
  display: flex;
  gap: 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.sa-13__frame {
  flex: 0 0 var(--sa-13-frame);
  scroll-snap-align: center;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  min-block-size: 9.5rem;
  padding: 0.625rem 0.75rem;
  border: 1px solid var(--rule);
  background: var(--page);
  min-inline-size: 0;
}

.sa-13__fnum {
  margin: 0 0 auto;
  font-size: 1.75rem;
  font-weight: 500;
  letter-spacing: -0.04em;
  color: var(--rule);
}

.sa-13__ft {
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 500;
}

.sa-13__fm {
  margin: 0;
  font-size: 0.6875rem;
  color: var(--muted);
}

.sa-13__frame:nth-child(9) {
  border-color: var(--accent);
}

.sa-13__frame:nth-child(9) .sa-13__ft {
  color: var(--accent);
}

.sa-13__facts {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: 0.5rem;
}

.sa-13__fact {
  padding: 0.625rem 0.75rem;
  border-block-start: 1px solid var(--rule);
  font-family: system-ui, sans-serif;
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--muted);
  min-inline-size: 0;
  text-wrap: pretty;
}

.sa-13__fk {
  font-family: var(--font-display);
  color: var(--accent);
}

@keyframes sa-13-fill {
  from {
    transform: scaleX(0.02);
  }

  to {
    transform: scaleX(1);
  }
}

@supports (animation-timeline: scroll()) {
  .sa-13__gfill {
    animation: sa-13-fill linear both;
    animation-timeline: --sa-13-rail;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sa-13__gfill {
    animation: none;
    transform: scaleX(1);
  }

  .sa-13__rail {
    scroll-behavior: auto;
  }
}

.sa-13__theme {
  position: absolute;
  inset-block-start: 0.875rem;
  inset-inline-end: 0.875rem;
  z-index: 9;
  display: inline-flex;
  gap: 0.125rem;
  margin: 0;
  padding: 0.1875rem;
  border: 1px solid var(--rule);
  background: var(--surface);
}

.sa-13__themelegend {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.sa-13__themeinput {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.sa-13__themeopt {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-block-size: 2.25rem;
  min-inline-size: 2.75rem;
  padding: 0 0.625rem;
  font-size: 0.625rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  cursor: pointer;
  user-select: none;
}

.sa-13__themeinput:checked + .sa-13__themeopt {
  color: var(--page);
  background: var(--accent);
}

.sa-13__themeinput:focus-visible + .sa-13__themeopt {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (max-width: 34rem) {
  .sa-13 {
    --sa-13-frame: 9.5rem;
    padding-block-start: 4.25rem;
  }
}

@media (forced-colors: active) {
  .sa-13__gfill {
    background: Highlight;
  }
}

@media (prefers-color-scheme: light) {
  .sa-13:not(:has(#sa-13-theme-dark:checked)) {
    --page: #ffffff;
    --surface: #f4f4f4;
    --ink: #101010;
    --muted: #5f5f5f;
    --rule: #dcdcdc;
    --accent: #3f7a00;
  }
}

@media (prefers-color-scheme: dark) {
  .sa-13:has(#sa-13-theme-light:checked) {
    --page: #ffffff;
    --surface: #f4f4f4;
    --ink: #101010;
    --muted: #5f5f5f;
    --rule: #dcdcdc;
    --accent: #3f7a00;
  }
}

[data-theme="light"] .sa-13:not(:has(#sa-13-theme-dark:checked)) {
  --page: #ffffff;
  --surface: #f4f4f4;
  --ink: #101010;
  --muted: #5f5f5f;
  --rule: #dcdcdc;
  --accent: #3f7a00;
}

.sa-13:has(#sa-13-theme-light:checked) {
  --page: #ffffff;
  --surface: #f4f4f4;
  --ink: #101010;
  --muted: #5f5f5f;
  --rule: #dcdcdc;
  --accent: #3f7a00;
}

.sa-13:has(#sa-13-theme-light:checked) .sa-13__themeinput:checked + .sa-13__themeopt {
  color: #ffffff;
}

.sa-13:has(#sa-13-theme-dark:checked) {
  --page: #000000;
  --surface: #0a0a0a;
  --ink: #f2f2f2;
  --muted: #7d7d7d;
  --rule: #1c1c1c;
  --accent: #b6ff2e;
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Scroll Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Scroll Timeline Axis and Horizontal Scrollers
Source: https://codefronts.com/motion/css-scroll-animations/kinetic-typography/

Scroll timelines are not vertical by default so much as block by default. Switching to scroll(inline) or scroll-timeline-axis: inline binds progress to horizontal scrolling instead, which is what a filmstrip, a card rail or a table with frozen columns needs. The indicator here tracks inline progress across a nine-frame rail.
## HTML
```html
<section class="sa-13" aria-labelledby="sa-13-h">
  <fieldset class="sa-13__theme">
    <legend class="sa-13__themelegend">Theme</legend>
    <input class="sa-13__themeinput" type="radio" name="sa-13-theme" id="sa-13-theme-auto" checked>
    <label class="sa-13__themeopt" for="sa-13-theme-auto">Auto</label>
    <input class="sa-13__themeinput" type="radio" name="sa-13-theme" id="sa-13-theme-light">
    <label class="sa-13__themeopt" for="sa-13-theme-light">Light</label>
    <input class="sa-13__themeinput" type="radio" name="sa-13-theme" id="sa-13-theme-dark">
    <label class="sa-13__themeopt" for="sa-13-theme-dark">Dark</label>
  </fieldset>

  <div class="sa-13__stage">
    <header class="sa-13__head">
      <p class="sa-13__eyebrow">axis: inline</p>
      <h2 class="sa-13__h" id="sa-13-h">The scrollbar that acts as the clock</h2>
      <p class="sa-13__sub">Drag the rail sideways, or focus it and use the arrow keys. Progress is read from the rail's horizontal overflow — <span class="sa-13__mono">scroll-timeline: --sa-13-rail inline</span>.</p>
    </header>

    <div class="sa-13__rail" tabindex="0" role="region" aria-label="Prism build frames, scrolls horizontally">
      <div class="sa-13__gauge">
        <p class="sa-13__glabel">inline<br>progress</p>
        <div class="sa-13__gtrack"><span class="sa-13__gfill"></span></div>
      </div>
      <ol class="sa-13__frames">
        <li class="sa-13__frame"><p class="sa-13__fnum">01</p><p class="sa-13__ft">Checkout</p><p class="sa-13__fm">prism/web · 0.9s</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">02</p><p class="sa-13__ft">Restore cache</p><p class="sa-13__fm">hit · 4.2s</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">03</p><p class="sa-13__ft">Install deps</p><p class="sa-13__fm">1,204 packages</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">04</p><p class="sa-13__ft">Typecheck</p><p class="sa-13__fm">no errors</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">05</p><p class="sa-13__ft">Unit tests</p><p class="sa-13__fm">912 passed</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">06</p><p class="sa-13__ft">Bundle</p><p class="sa-13__fm">412 kB gzip</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">07</p><p class="sa-13__ft">Visual diff</p><p class="sa-13__fm">2 to review</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">08</p><p class="sa-13__ft">Sign artifacts</p><p class="sa-13__fm">Priya Sharma</p></li>
        <li class="sa-13__frame"><p class="sa-13__fnum">09</p><p class="sa-13__ft">Deploy to production</p><p class="sa-13__fm">Sable · eu-west</p></li>
      </ol>
    </div>

    <ul class="sa-13__facts">
      <li class="sa-13__fact"><span class="sa-13__fk">inline</span> resolves through writing mode. <span class="sa-13__fk">x</span> is physical and never changes.</li>
      <li class="sa-13__fact">The axis belongs to the timeline owner. Consumers only name the timeline.</li>
      <li class="sa-13__fact">No <span class="sa-13__mono">overflow-x</span> means no inline scroll to read, and no error either.</li>
    </ul>
  </div>
</section>
```
## CSS
```css
.sa-13 {
  --page: #000000;
  --surface: #0a0a0a;
  --ink: #f2f2f2;
  --muted: #7d7d7d;
  --rule: #1c1c1c;
  --accent: #b6ff2e;
  --page: oklch(0% 0 0);
  --surface: oklch(12% 0 0);
  --ink: oklch(96% 0 0);
  --muted: oklch(58% 0 0);
  --rule: oklch(21% 0 0);
  --accent: oklch(90% 0.22 122);
  --sa-13-frame: 11rem;
  --font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  position: relative;
  padding: 3.5rem 1rem 2.5rem;
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-display);
}

.sa-13 *,
.sa-13 *::before,
.sa-13 *::after {
  box-sizing: border-box;
}

.sa-13__stage {
  max-inline-size: 64rem;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 0.875rem;
}

.sa-13__head {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
}

.sa-13__eyebrow {
  margin: 0;
  font-size: 0.6875rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
}

.sa-13__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 3.6vw, 1.875rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1.1;
}

.sa-13__sub {
  margin: 0;
  max-inline-size: 46rem;
  font-family: system-ui, sans-serif;
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sa-13__mono {
  color: var(--accent);
}

.sa-13__rail {
  display: flex;
  align-items: stretch;
  gap: 0.5rem;
  padding: 0.5rem;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-inline: contain;
  scroll-snap-type: inline mandatory;
  scroll-timeline: --sa-13-rail inline;
  background: var(--surface);
}

.sa-13__rail:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.sa-13__gauge {
  position: sticky;
  inset-inline-start: 0;
  z-index: 2;
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 0.5rem;
  inline-size: 4.25rem;
  padding: 0.625rem 0.5rem;
  background: var(--page);
}

.sa-13__glabel {
  margin: 0;
  font-size: 0.5625rem;
  line-height: 1.3;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

.sa-13__gtrack {
  block-size: 0.375rem;
  background: var(--rule);
}

.sa-13__gfill {
  display: block;
  block-size: 100%;
  inline-size: 100%;
  background: var(--accent);
  transform-origin: left center;
  transform: scaleX(1);
}

.sa-13__frames {
  display: flex;
  gap: 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.sa-13__frame {
  flex: 0 0 var(--sa-13-frame);
  scroll-snap-align: center;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  min-block-size: 9.5rem;
  padding: 0.625rem 0.75rem;
  border: 1px solid var(--rule);
  background: var(--page);
  min-inline-size: 0;
}

.sa-13__fnum {
  margin: 0 0 auto;
  font-size: 1.75rem;
  font-weight: 500;
  letter-spacing: -0.04em;
  color: var(--rule);
}

.sa-13__ft {
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 500;
}

.sa-13__fm {
  margin: 0;
  font-size: 0.6875rem;
  color: var(--muted);
}

.sa-13__frame:nth-child(9) {
  border-color: var(--accent);
}

.sa-13__frame:nth-child(9) .sa-13__ft {
  color: var(--accent);
}

.sa-13__facts {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: 0.5rem;
}

.sa-13__fact {
  padding: 0.625rem 0.75rem;
  border-block-start: 1px solid var(--rule);
  font-family: system-ui, sans-serif;
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--muted);
  min-inline-size: 0;
  text-wrap: pretty;
}

.sa-13__fk {
  font-family: var(--font-display);
  color: var(--accent);
}

@keyframes sa-13-fill {
  from {
    transform: scaleX(0.02);
  }

  to {
    transform: scaleX(1);
  }
}

@supports (animation-timeline: scroll()) {
  .sa-13__gfill {
    animation: sa-13-fill linear both;
    animation-timeline: --sa-13-rail;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sa-13__gfill {
    animation: none;
    transform: scaleX(1);
  }

  .sa-13__rail {
    scroll-behavior: auto;
  }
}

.sa-13__theme {
  position: absolute;
  inset-block-start: 0.875rem;
  inset-inline-end: 0.875rem;
  z-index: 9;
  display: inline-flex;
  gap: 0.125rem;
  margin: 0;
  padding: 0.1875rem;
  border: 1px solid var(--rule);
  background: var(--surface);
}

.sa-13__themelegend {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.sa-13__themeinput {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.sa-13__themeopt {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-block-size: 2.25rem;
  min-inline-size: 2.75rem;
  padding: 0 0.625rem;
  font-size: 0.625rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  cursor: pointer;
  user-select: none;
}

.sa-13__themeinput:checked + .sa-13__themeopt {
  color: var(--page);
  background: var(--accent);
}

.sa-13__themeinput:focus-visible + .sa-13__themeopt {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (max-width: 34rem) {
  .sa-13 {
    --sa-13-frame: 9.5rem;
    padding-block-start: 4.25rem;
  }
}

@media (forced-colors: active) {
  .sa-13__gfill {
    background: Highlight;
  }
}

@media (prefers-color-scheme: light) {
  .sa-13:not(:has(#sa-13-theme-dark:checked)) {
    --page: #ffffff;
    --surface: #f4f4f4;
    --ink: #101010;
    --muted: #5f5f5f;
    --rule: #dcdcdc;
    --accent: #3f7a00;
  }
}

@media (prefers-color-scheme: dark) {
  .sa-13:has(#sa-13-theme-light:checked) {
    --page: #ffffff;
    --surface: #f4f4f4;
    --ink: #101010;
    --muted: #5f5f5f;
    --rule: #dcdcdc;
    --accent: #3f7a00;
  }
}

[data-theme="light"] .sa-13:not(:has(#sa-13-theme-dark:checked)) {
  --page: #ffffff;
  --surface: #f4f4f4;
  --ink: #101010;
  --muted: #5f5f5f;
  --rule: #dcdcdc;
  --accent: #3f7a00;
}

.sa-13:has(#sa-13-theme-light:checked) {
  --page: #ffffff;
  --surface: #f4f4f4;
  --ink: #101010;
  --muted: #5f5f5f;
  --rule: #dcdcdc;
  --accent: #3f7a00;
}

.sa-13:has(#sa-13-theme-light:checked) .sa-13__themeinput:checked + .sa-13__themeopt {
  color: #ffffff;
}

.sa-13:has(#sa-13-theme-dark:checked) {
  --page: #000000;
  --surface: #0a0a0a;
  --ink: #f2f2f2;
  --muted: #7d7d7d;
  --rule: #1c1c1c;
  --accent: #b6ff2e;
}
```

How this works

The axis keyword describes the scroller, not the animation. scroll(inline) does not mean the animation moves horizontally — it means progress is read from the scroller's horizontal overflow. A vertical fade can be driven by horizontal scrolling and vice versa; the axis argument only chooses which scrollbar is the clock.

block and inline follow writing mode; x and y do not. In a standard horizontal-tb English layout inline resolves to x. Flip to writing-mode: vertical-rl and inline becomes the vertical axis while x stays physical. For components that get localised, the logical keywords are the safer default; for a rail you know will always be horizontal, x is more literal.

Named or anonymous, the axis lives with the owner. scroll-timeline: --sa-13-rail inline is the shorthand for scroll-timeline-name plus scroll-timeline-axis. Consumers just reference the name — they cannot override the axis, and trying to set it on the consumer is a silent no-op.

The trap is a rail that only looks scrollable. A flex row overflows horizontally, but if it lacks overflow-x: auto it is not a scroll container, so there is no inline scroll to read and the indicator never moves. Its sibling trap: overflow-x: auto with flex-wrap: wrap still on means the items wrap instead of overflowing, so the container scrolls in the block axis and an inline binding reads a range of zero.

Make it yours

  • Swap inline for x in the shorthand — identical here, different the moment the component is set in a vertical writing mode.
  • Add animation-range: 0% 80% to the fill so the indicator completes before the last frame.
  • Change scroll-snap-align on the frames from center to start for a left-edge rail.
  • Raise --sa-13-frame to widen every frame and lengthen the scrollable distance.
  • Delete scroll-snap-type to compare free inline scrolling against snapped, ahead of demo 15.
  • Point --sa-13-accent at another hue; the rail keeps its true-black surface either way.

Gotchas — read before shipping

  • A horizontally overflowing flex row is not automatically a scroll container — without overflow-x: auto or scroll there is no inline scroll to bind to.
  • Leaving flex-wrap: wrap on the rail makes items wrap rather than overflow, so an inline timeline has zero range and nothing moves.
  • block and inline resolve through writing mode while x and y are physical; mixing them in one component produces animations that reverse when localised.
  • Declaring the axis on the consumer instead of the timeline owner is ignored, which reads as the axis argument having no effect.

Browser support

ChromeSafariFirefoxEdge
115+not yet136+115+

scroll-timeline-axis sets the floor at Chrome 115 and Firefox 136 unflagged; Safari has not shipped scroll-driven animations, so the inline binding does not resolve there. The rail itself is ordinary horizontal overflow with scroll-snap-type, Baseline since Safari 11, so Safari users get a fully usable snapping filmstrip with the indicator declared at full progress — the base layer is the finished state and the binding sits inside @supports (animation-timeline: scroll()).

Techniques used in this demo

Search CodeFronts

Loading…