32 CSS Timelines26 / 32

Pure CSSMIT licensed

Horizontal Panorama Timeline — Vertical Scroll Drives Sideways Travel

The collection's finale: a century of milestones laid out as one wide horizontal panorama that travels sideways as you scroll DOWN — the sticky-viewport + scroll-driven-transform pattern behind award-site history pages, finally possible in pure CSS with animation-timeline: scroll(). No scroll hijacking, no wheel listeners: the scrollbar keeps its native physics and the panorama is simply mapped onto it.

Published Updated

Live Demo
Try it

The code

<section class="tls-26" aria-label="Horizontal panorama timeline demo">
  <div class="tls-26__runway">
    <div class="tls-26__stage">
      <p class="tls-26__hint">Keep scrolling — the century moves sideways ↓</p>
      <div class="tls-26__window">
        <ol class="tls-26__track" role="list">
          <li class="tls-26__card"><time datetime="1926">1926</time><h4>Television demonstrated</h4><p>Baird's flickering 30-line image convinces a skeptical press in a Soho attic.</p></li>
          <li class="tls-26__card"><time datetime="1947">1947</time><h4>The transistor</h4><p>Three Bell Labs physicists replace the vacuum tube and miniaturize the century.</p></li>
          <li class="tls-26__card"><time datetime="1969">1969</time><h4>ARPANET's first message</h4><p>'LO' — the network crashes before the 'GIN'. It never really stops after that.</p></li>
          <li class="tls-26__card"><time datetime="1989">1989</time><h4>The Web proposed</h4><p>'Vague, but exciting' — the most consequential margin note in computing.</p></li>
          <li class="tls-26__card"><time datetime="2007">2007</time><h4>The smartphone era</h4><p>A phone, an iPod and an internet communicator walk into a keynote.</p></li>
          <li class="tls-26__card"><time datetime="2026">2026</time><h4>CSS animates on scroll</h4><p>The scrollbar becomes a timeline — this page is the demo.</p></li>
        </ol>
      </div>
      <div class="tls-26__ruler" aria-hidden="true"><span>1920s</span><span>1940s</span><span>1960s</span><span>1980s</span><span>2000s</span><span>2020s</span></div>
    </div>
  </div>
</section>
.tls-26,
.tls-26 *,
.tls-26 *::before,
.tls-26 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tls-26 {
  min-height: 100vh;
  width: 100%;
  --tl-accent: #f59e0b;
  --tl-line: #31313b;
  --tl-ink: #f2f2f4;
  --tl-mut: #9b9ba8;
  font-family: 'Segoe UI',system-ui,sans-serif;
  background: #16161c;
}

.tls-26__runway {
  height: 320vh;
}

.tls-26__stage {
  position: sticky;
  top: 0;
  height: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 10px;
  padding: 28px 0;
  overflow: clip;
}

.tls-26__hint {
  text-align: center;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--tl-mut);
}

.tls-26__window {
  display: grid;
  align-items: center;
  overflow: clip;
}

.tls-26__track {
  list-style: none;
  display: flex;
  gap: 40px;
  width: max-content;
  padding: 0 8vw;
  position: relative;
  align-items: stretch;
}

.tls-26__track::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  background: var(--tl-line);
}

.tls-26__card {
  position: relative;
  flex: none;
  width: min(340px,72vw);
  background: #1e1e26;
  border: 1px solid var(--tl-line);
  border-radius: 16px;
  padding: 22px 24px;
  z-index: 1;
}

.tls-26__card::before {
  content: '';
  position: absolute;
  top: -27px;
  left: 28px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--tl-accent);
  border: 3px solid #16161c;
}

.tls-26__card time {
  font-size: 26px;
  font-weight: 800;
  color: var(--tl-accent);
  letter-spacing: -.02em;
}

.tls-26__card h4 {
  font-size: 16.5px;
  font-weight: 750;
  color: var(--tl-ink);
  margin: 8px 0 7px;
  letter-spacing: -.01em;
}

.tls-26__card p {
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--tl-mut);
  text-wrap: pretty;
}

.tls-26__ruler {
  display: flex;
  justify-content: space-between;
  padding: 0 6vw;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .14em;
  color: #55555f;
}

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .tls-26__track {
      animation: tls-26-pan linear both;
      animation-timeline: scroll();
    }

    @keyframes tls-26-pan {
      to {
        transform: translateX(calc(-100% + 100vw));
      }
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .tls-26__runway {
      height: auto;
    }

    .tls-26__stage {
      position: static;
      height: auto;
    }

    .tls-26__window {
      overflow-x: auto;
    }
  }
}

@supports not (animation-timeline: scroll()) {
  .tls-26__runway {
    height: auto;
  }

  .tls-26__stage {
    position: static;
    height: auto;
  }

  .tls-26__window {
    overflow-x: auto;
    scroll-snap-type: x proximity;
  }

  .tls-26__card {
    scroll-snap-align: center;
  }
}
/* No JavaScript — vertical progress through the 320vh runway drives translateX via animation-timeline: scroll(); the @supports not (and reduced-motion) paths serve the same century as a native horizontal scroller. */
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 Timeline 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: Horizontal Panorama Timeline — Vertical Scroll Drives Sideways Travel
Source: https://codefronts.com/layouts/css-timelines/brutalist-grid/

The collection's finale: a century of milestones laid out as one wide horizontal panorama that travels sideways as you scroll DOWN — the sticky-viewport + scroll-driven-transform pattern behind award-site history pages, finally possible in pure CSS with animation-timeline: scroll(). No scroll hijacking, no wheel listeners: the scrollbar keeps its native physics and the panorama is simply mapped onto it.
## HTML
```html
<section class="tls-26" aria-label="Horizontal panorama timeline demo">
  <div class="tls-26__runway">
    <div class="tls-26__stage">
      <p class="tls-26__hint">Keep scrolling — the century moves sideways ↓</p>
      <div class="tls-26__window">
        <ol class="tls-26__track" role="list">
          <li class="tls-26__card"><time datetime="1926">1926</time><h4>Television demonstrated</h4><p>Baird's flickering 30-line image convinces a skeptical press in a Soho attic.</p></li>
          <li class="tls-26__card"><time datetime="1947">1947</time><h4>The transistor</h4><p>Three Bell Labs physicists replace the vacuum tube and miniaturize the century.</p></li>
          <li class="tls-26__card"><time datetime="1969">1969</time><h4>ARPANET's first message</h4><p>'LO' — the network crashes before the 'GIN'. It never really stops after that.</p></li>
          <li class="tls-26__card"><time datetime="1989">1989</time><h4>The Web proposed</h4><p>'Vague, but exciting' — the most consequential margin note in computing.</p></li>
          <li class="tls-26__card"><time datetime="2007">2007</time><h4>The smartphone era</h4><p>A phone, an iPod and an internet communicator walk into a keynote.</p></li>
          <li class="tls-26__card"><time datetime="2026">2026</time><h4>CSS animates on scroll</h4><p>The scrollbar becomes a timeline — this page is the demo.</p></li>
        </ol>
      </div>
      <div class="tls-26__ruler" aria-hidden="true"><span>1920s</span><span>1940s</span><span>1960s</span><span>1980s</span><span>2000s</span><span>2020s</span></div>
    </div>
  </div>
</section>
```
## CSS
```css
.tls-26,
.tls-26 *,
.tls-26 *::before,
.tls-26 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tls-26 {
  min-height: 100vh;
  width: 100%;
  --tl-accent: #f59e0b;
  --tl-line: #31313b;
  --tl-ink: #f2f2f4;
  --tl-mut: #9b9ba8;
  font-family: 'Segoe UI',system-ui,sans-serif;
  background: #16161c;
}

.tls-26__runway {
  height: 320vh;
}

.tls-26__stage {
  position: sticky;
  top: 0;
  height: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 10px;
  padding: 28px 0;
  overflow: clip;
}

.tls-26__hint {
  text-align: center;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--tl-mut);
}

.tls-26__window {
  display: grid;
  align-items: center;
  overflow: clip;
}

.tls-26__track {
  list-style: none;
  display: flex;
  gap: 40px;
  width: max-content;
  padding: 0 8vw;
  position: relative;
  align-items: stretch;
}

.tls-26__track::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  background: var(--tl-line);
}

.tls-26__card {
  position: relative;
  flex: none;
  width: min(340px,72vw);
  background: #1e1e26;
  border: 1px solid var(--tl-line);
  border-radius: 16px;
  padding: 22px 24px;
  z-index: 1;
}

.tls-26__card::before {
  content: '';
  position: absolute;
  top: -27px;
  left: 28px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--tl-accent);
  border: 3px solid #16161c;
}

.tls-26__card time {
  font-size: 26px;
  font-weight: 800;
  color: var(--tl-accent);
  letter-spacing: -.02em;
}

.tls-26__card h4 {
  font-size: 16.5px;
  font-weight: 750;
  color: var(--tl-ink);
  margin: 8px 0 7px;
  letter-spacing: -.01em;
}

.tls-26__card p {
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--tl-mut);
  text-wrap: pretty;
}

.tls-26__ruler {
  display: flex;
  justify-content: space-between;
  padding: 0 6vw;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .14em;
  color: #55555f;
}

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .tls-26__track {
      animation: tls-26-pan linear both;
      animation-timeline: scroll();
    }

    @keyframes tls-26-pan {
      to {
        transform: translateX(calc(-100% + 100vw));
      }
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .tls-26__runway {
      height: auto;
    }

    .tls-26__stage {
      position: static;
      height: auto;
    }

    .tls-26__window {
      overflow-x: auto;
    }
  }
}

@supports not (animation-timeline: scroll()) {
  .tls-26__runway {
    height: auto;
  }

  .tls-26__stage {
    position: static;
    height: auto;
  }

  .tls-26__window {
    overflow-x: auto;
    scroll-snap-type: x proximity;
  }

  .tls-26__card {
    scroll-snap-align: center;
  }
}
```

## JavaScript
```js
/* No JavaScript — vertical progress through the 320vh runway drives translateX via animation-timeline: scroll(); the @supports not (and reduced-motion) paths serve the same century as a native horizontal scroller. */
```

How this works

The structure is a tall section (height: 320vh) containing a position: sticky; top: 0; height: 100vh stage. As the user scrolls through the tall section, the stage stays pinned — and the wide track inside it runs animation: translateX(0 → -75%); animation-timeline: scroll(), with the section itself named as the timeline scope via timeline-scope-free nearest-scroller mapping. Vertical progress THROUGH the section becomes horizontal travel ACROSS the century: 320vh of scroll ≈ four viewport-widths of panorama, an honest exchange rate the user's scrollbar still fully controls.

Milestones are cards spaced along the track with a continuous rail behind them; a decade ruler runs beneath. Because the motion is scroll-mapped (not time-based), the user can stop mid-decade, scrub backwards, or fling to the end — it's navigation, not a cutscene, which is what separates this pattern from the scroll-hijacking it replaced.

The fallbacks are first-class, not afterthoughts: without animation-timeline support, an @supports not block un-pins the stage and lets the track scroll natively sideways (overflow-x:auto + scroll-snap) — same content, same order, standard interaction. Under reduced motion the same native-scroll layout applies, because a full-viewport panorama lurching with the scrollbar is exactly the motion such users opted out of.

Techniques used: sticky 100vh stage inside a 320vh scroll runway · animation-timeline: scroll() mapping vertical progress to translateX · scrubbing/navigation semantics (never hijacked wheel events) · @supports not fallback to native overflow-x + scroll-snap · reduced-motion served by the same native-scroll path · decade ruler + card rail composition · transform-only motion (compositor-driven)

Make it yours

  • Journey length: the 320vh runway — more vh per viewport-width of track = slower, more luxurious travel.
  • Travel distance: the -75% keyframe = (track width − one viewport) as a fraction of track; recalculate when adding cards.
  • Snap in the fallback: scroll-snap-align on cards makes the no-support path feel designed, not degraded.
  • Decade ruler: pure flex labels — swap for years, versions, chapters.

Gotchas — read before shipping

  • The tall section provides the scroll RUNWAY — sticky inside it needs the section to be genuinely taller than 100vh or nothing pins and the animation has no range.
  • translateX percentage is relative to the TRACK's width — adding cards without updating the -75% leaves the last cards unreachable.
  • Never implement this with wheel listeners + preventDefault — that's scroll hijacking; scroll() timelines keep native physics, momentum and accessibility.
  • Keyboard/screen-reader users traverse the cards in DOM order regardless of the visual panorama — keep that order chronological and this stays accessible; test with Tab.
  • iOS Safari requires the sticky stage to have no overflowing ancestor with overflow:hidden (use overflow:clip) — the classic sticky killer strikes here too.

Browser support

ChromeSafariFirefoxEdge
115+18.2+ (partial 17.4)128+ (flag; 2025 stable)115+

Scroll-driven animations are Baseline newly-available 2025; the @supports not fallback delivers the identical century as a native horizontal scroller everywhere else.

Techniques used in this demo

Search CodeFronts

Loading…