32 CSS Timelines24 / 32

Pure CSSMIT licensed

Scrollytelling Timeline — Sticky Chapter Panel with Scrolling Beats

The long-form journalism pattern: a chapter panel (giant year, chapter title, running progress dots) pins to the left while its story beats scroll past on the right, then the next chapter's panel takes over — the NYT-interactive structure, built from nothing but position:sticky and grid, no scroll library, no observers.

Published Updated

Live Demo
Try it

The code

<section class="tls-24" aria-label="Scrollytelling timeline demo">
  <div class="tls-24__inner">
    <section class="tls-24__chapter" id="tls30-c1">
      <aside class="tls-24__panel">
        <p class="tls-24__num">Chapter 1</p>
        <p class="tls-24__year" aria-hidden="true">2019</p>
        <h4 class="tls-24__ct">The problem nobody claimed</h4>
        <p class="tls-24__dots" aria-label="Chapter 1 of 3"><span class="tls-24__dot tls-24__dot--on"></span><span class="tls-24__dot"></span><span class="tls-24__dot"></span></p>
      </aside>
      <div class="tls-24__beats">
        <div class="tls-24__beat"><p>Every team had the same spreadsheet. Nobody owned it, everybody edited it, and once a quarter it broke payroll.</p></div>
        <div class="tls-24__beat"><p>Three engineers started fixing it on Friday afternoons. Their manager pretended not to notice, which was the budget.</p></div>
      </div>
    </section>
    <section class="tls-24__chapter" id="tls30-c2">
      <aside class="tls-24__panel">
        <p class="tls-24__num">Chapter 2</p>
        <p class="tls-24__year" aria-hidden="true">2022</p>
        <h4 class="tls-24__ct">Accidentally a product</h4>
        <p class="tls-24__dots" aria-label="Chapter 2 of 3"><span class="tls-24__dot"></span><span class="tls-24__dot tls-24__dot--on"></span><span class="tls-24__dot"></span></p>
      </aside>
      <div class="tls-24__beats">
        <div class="tls-24__beat"><p>A partner company asked to license 'the payroll thing'. The invoice was written before the name was.</p></div>
        <div class="tls-24__beat"><p>By December, external revenue passed the team's own cost. The Friday project got a Monday-to-Friday budget.</p></div>
      </div>
    </section>
    <section class="tls-24__chapter" id="tls30-c3">
      <aside class="tls-24__panel">
        <p class="tls-24__num">Chapter 3</p>
        <p class="tls-24__year" aria-hidden="true">2026</p>
        <h4 class="tls-24__ct">The spin-out</h4>
        <p class="tls-24__dots" aria-label="Chapter 3 of 3"><span class="tls-24__dot"></span><span class="tls-24__dot"></span><span class="tls-24__dot tls-24__dot--on"></span></p>
      </aside>
      <div class="tls-24__beats">
        <div class="tls-24__beat"><p>Forty people, four hundred customers, and the original spreadsheet framed in the lobby — cell C7 still showing #REF!.</p></div>
      </div>
    </section>
  </div>
</section>
.tls-24,
.tls-24 *,
.tls-24 *::before,
.tls-24 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tls-24 {
  min-height: 100vh;
  width: 100%;
  --tl-accent: #9a3412;
  --tl-line: #eadfd6;
  --tl-ink: #292018;
  --tl-mut: #8a7767;
  font-family: Georgia,'Times New Roman',serif;
  background: #faf6f2;
  padding: 64px 24px;
}

.tls-24__inner {
  width: min(820px,100%);
  margin-inline: auto;
}

.tls-24__chapter {
  display: grid;
  grid-template-columns: 240px 1fr;
  gap: 40px;
  padding-bottom: 60px;
}

.tls-24__panel {
  position: sticky;
  top: 15vh;
  align-self: start;
  padding-top: 8px;
}

.tls-24__num {
  font-family: 'Segoe UI',system-ui,sans-serif;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--tl-accent);
}

.tls-24__year {
  font-size: clamp(44px,7vw,72px);
  font-weight: 700;
  color: var(--tl-ink);
  letter-spacing: -.04em;
  line-height: 1;
  margin: 8px 0 10px;
}

.tls-24__ct {
  font-family: 'Segoe UI',system-ui,sans-serif;
  font-size: 15px;
  font-weight: 750;
  color: var(--tl-ink);
  letter-spacing: -.01em;
  line-height: 1.35;
  margin-bottom: 16px;
  text-wrap: balance;
}

.tls-24__dots {
  display: flex;
  gap: 6px;
}

.tls-24__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: 2px solid var(--tl-mut);
  display: inline-block;
}

.tls-24__dot--on {
  background: var(--tl-accent);
  border-color: var(--tl-accent);
}

.tls-24__beats {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.tls-24__beat {
  min-height: 32vh;
  display: grid;
  align-content: center;
  background: #fff;
  border: 1px solid var(--tl-line);
  border-radius: 16px;
  padding: 34px 36px;
}

.tls-24__beat p {
  font-size: clamp(16px,2.2vw,19px);
  line-height: 1.75;
  color: #463a2f;
  text-wrap: pretty;
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .tls-24__beat {
      animation: tls-24-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 50%;
    }

    @keyframes tls-24-in {
      from {
        opacity: .2;
        transform: translateY(16px);
      }

      to {
        opacity: 1;
        transform: none;
      }
    }
  }
}

@media (max-width: 640px) {
  .tls-24__chapter {
    grid-template-columns: 1fr;
    gap: 20px;
  }

  .tls-24__panel {
    position: static;
  }
}
/* No JavaScript — chapter panels are position:sticky with align-self:start (mandatory in grid, or sticky silently fails); chapter handoff is containment physics; the beat entrance is an optional view() layer behind a double gate. */
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: Scrollytelling Timeline — Sticky Chapter Panel with Scrolling Beats
Source: https://codefronts.com/layouts/css-timelines/editorial/

The long-form journalism pattern: a chapter panel (giant year, chapter title, running progress dots) pins to the left while its story beats scroll past on the right, then the next chapter's panel takes over — the NYT-interactive structure, built from nothing but position:sticky and grid, no scroll library, no observers.
## HTML
```html
<section class="tls-24" aria-label="Scrollytelling timeline demo">
  <div class="tls-24__inner">
    <section class="tls-24__chapter" id="tls30-c1">
      <aside class="tls-24__panel">
        <p class="tls-24__num">Chapter 1</p>
        <p class="tls-24__year" aria-hidden="true">2019</p>
        <h4 class="tls-24__ct">The problem nobody claimed</h4>
        <p class="tls-24__dots" aria-label="Chapter 1 of 3"><span class="tls-24__dot tls-24__dot--on"></span><span class="tls-24__dot"></span><span class="tls-24__dot"></span></p>
      </aside>
      <div class="tls-24__beats">
        <div class="tls-24__beat"><p>Every team had the same spreadsheet. Nobody owned it, everybody edited it, and once a quarter it broke payroll.</p></div>
        <div class="tls-24__beat"><p>Three engineers started fixing it on Friday afternoons. Their manager pretended not to notice, which was the budget.</p></div>
      </div>
    </section>
    <section class="tls-24__chapter" id="tls30-c2">
      <aside class="tls-24__panel">
        <p class="tls-24__num">Chapter 2</p>
        <p class="tls-24__year" aria-hidden="true">2022</p>
        <h4 class="tls-24__ct">Accidentally a product</h4>
        <p class="tls-24__dots" aria-label="Chapter 2 of 3"><span class="tls-24__dot"></span><span class="tls-24__dot tls-24__dot--on"></span><span class="tls-24__dot"></span></p>
      </aside>
      <div class="tls-24__beats">
        <div class="tls-24__beat"><p>A partner company asked to license 'the payroll thing'. The invoice was written before the name was.</p></div>
        <div class="tls-24__beat"><p>By December, external revenue passed the team's own cost. The Friday project got a Monday-to-Friday budget.</p></div>
      </div>
    </section>
    <section class="tls-24__chapter" id="tls30-c3">
      <aside class="tls-24__panel">
        <p class="tls-24__num">Chapter 3</p>
        <p class="tls-24__year" aria-hidden="true">2026</p>
        <h4 class="tls-24__ct">The spin-out</h4>
        <p class="tls-24__dots" aria-label="Chapter 3 of 3"><span class="tls-24__dot"></span><span class="tls-24__dot"></span><span class="tls-24__dot tls-24__dot--on"></span></p>
      </aside>
      <div class="tls-24__beats">
        <div class="tls-24__beat"><p>Forty people, four hundred customers, and the original spreadsheet framed in the lobby — cell C7 still showing #REF!.</p></div>
      </div>
    </section>
  </div>
</section>
```
## CSS
```css
.tls-24,
.tls-24 *,
.tls-24 *::before,
.tls-24 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tls-24 {
  min-height: 100vh;
  width: 100%;
  --tl-accent: #9a3412;
  --tl-line: #eadfd6;
  --tl-ink: #292018;
  --tl-mut: #8a7767;
  font-family: Georgia,'Times New Roman',serif;
  background: #faf6f2;
  padding: 64px 24px;
}

.tls-24__inner {
  width: min(820px,100%);
  margin-inline: auto;
}

.tls-24__chapter {
  display: grid;
  grid-template-columns: 240px 1fr;
  gap: 40px;
  padding-bottom: 60px;
}

.tls-24__panel {
  position: sticky;
  top: 15vh;
  align-self: start;
  padding-top: 8px;
}

.tls-24__num {
  font-family: 'Segoe UI',system-ui,sans-serif;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--tl-accent);
}

.tls-24__year {
  font-size: clamp(44px,7vw,72px);
  font-weight: 700;
  color: var(--tl-ink);
  letter-spacing: -.04em;
  line-height: 1;
  margin: 8px 0 10px;
}

.tls-24__ct {
  font-family: 'Segoe UI',system-ui,sans-serif;
  font-size: 15px;
  font-weight: 750;
  color: var(--tl-ink);
  letter-spacing: -.01em;
  line-height: 1.35;
  margin-bottom: 16px;
  text-wrap: balance;
}

.tls-24__dots {
  display: flex;
  gap: 6px;
}

.tls-24__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: 2px solid var(--tl-mut);
  display: inline-block;
}

.tls-24__dot--on {
  background: var(--tl-accent);
  border-color: var(--tl-accent);
}

.tls-24__beats {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.tls-24__beat {
  min-height: 32vh;
  display: grid;
  align-content: center;
  background: #fff;
  border: 1px solid var(--tl-line);
  border-radius: 16px;
  padding: 34px 36px;
}

.tls-24__beat p {
  font-size: clamp(16px,2.2vw,19px);
  line-height: 1.75;
  color: #463a2f;
  text-wrap: pretty;
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .tls-24__beat {
      animation: tls-24-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 50%;
    }

    @keyframes tls-24-in {
      from {
        opacity: .2;
        transform: translateY(16px);
      }

      to {
        opacity: 1;
        transform: none;
      }
    }
  }
}

@media (max-width: 640px) {
  .tls-24__chapter {
    grid-template-columns: 1fr;
    gap: 20px;
  }

  .tls-24__panel {
    position: static;
  }
}
```

## JavaScript
```js
/* No JavaScript — chapter panels are position:sticky with align-self:start (mandatory in grid, or sticky silently fails); chapter handoff is containment physics; the beat entrance is an optional view() layer behind a double gate. */
```

How this works

Each chapter is a two-column grid: aside (the panel) + div (the beats). The panel declares position: sticky; top: 15vh; align-self: start — align-self matters, because a stretched grid item is as tall as its track and has nowhere to stick. As the beats column (much taller) scrolls, the panel holds position; when the chapter ends, the next chapter's panel pushes in. The entire scroll choreography is containment physics.

The panel's chapter-progress dots are pure structure — chapter 2 of 3 shows dot 2 filled — communicating 'where am I in this piece' without measuring scroll. Beats are generous cards with breathing room (min-height:45vh spacing rhythm) because scrollytelling dies when beats arrive faster than they can be read; the whitespace IS the pacing.

Beat entrances reuse demo 17's view() gate as an optional layer — inside @supports + no-preference, beats drift up 16px as they arrive. Without support: a perfectly readable static article. This demo is deliberately the 'assembly' of the collection: sticky (20/21) + scroll-driven (17/25) + editorial type (20) composed into the highest-value pattern.

Techniques used: sticky chapter panels with align-self:start in grid (the #1 sticky-in-grid gotcha, solved) · natural chapter handoff via containment · structural progress dots (no measurement) · 45vh beat rhythm for reading pace · optional view() entrance layer behind double gate (@supports + no-preference) · chapters as sections with ids for deep links

Make it yours

  • Pin height: top:15vh — smaller pins higher; match your header.
  • Beat pacing: the min-height rhythm on beats — longer for image-led stories, shorter for stat runs.
  • Panel content: the year is decorative scale; swap in a chapter image or an animated counter.
  • Full-bleed media beats: any beat can span both columns by breaking out of the grid (grid-column:1/-1 on a nested wrapper).

Gotchas — read before shipping

  • align-self:start on the sticky panel is MANDATORY in grid — default stretch makes the item full-track-height and sticky silently does nothing. This single line answers a thousand Stack Overflow posts.
  • No overflow:hidden anywhere up the ancestor chain — it kills sticky; use overflow:clip if you must clip decoration.
  • Chapters need real height contrast: if beats are barely taller than the panel, the pin lasts 40px and reads as jitter — merge short chapters.
  • Keep the panel non-interactive or fully reachable: content that pins over links can trap keyboard focus underneath on some readers.

Browser support

ChromeSafariFirefoxEdge
115+26+not yet115+

Floor set by animation-timeline, text-wrap as this demo is written. The core technique itself goes back further — Chrome all.

The core is sticky + grid (baseline 2017). Only the optional entrance layer is gated behind animation-timeline support (2025 Baseline).

Techniques used in this demo

Search CodeFronts

Loading…