32 CSS Timelines21 / 32

Pure CSSMIT licensed

Zigzag Alternating Timeline — Container Query Responsive

The classic center-spine zigzag — cards alternating left and right of a vertical rail with arrow notches pointing at their markers — made truly portable: it reshapes to a single left-rail column when its CONTAINER drops below 560px, not the viewport. Drop it in a sidebar, a modal or a split view and it lays itself out correctly with zero configuration.

Published Updated

Live Demo
Try it

The code

<section class="tls-21" aria-label="Zigzag container query timeline demo">
  <div class="tls-21__frame">
    <h3 class="tls-21__title">Company milestones</h3>
    <div class="tls-21__container">
      <ol class="tls-21__grid" role="list">
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2022">2022</time>
            <h4 class="tls-21__h">Founded in a garage</h4>
            <p class="tls-21__p">Two founders, one whiteboard, no name for six weeks.</p>
          </div>
        </li>
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2023">2023</time>
            <h4 class="tls-21__h">Seed round</h4>
            <p class="tls-21__p">$2.4M to hire the first five engineers and a very good office dog.</p>
          </div>
        </li>
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2024">2024</time>
            <h4 class="tls-21__h">First enterprise customer</h4>
            <p class="tls-21__p">A 3,000-seat rollout that rewrote our definition of 'edge case'.</p>
          </div>
        </li>
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2025">2025</time>
            <h4 class="tls-21__h">Series A</h4>
            <p class="tls-21__p">$18M led by our earliest angel. The dog got a title.</p>
          </div>
        </li>
      </ol>
    </div>
  </div>
</section>
.tls-21,
.tls-21 *,
.tls-21 *::before,
.tls-21 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tls-21 {
  min-height: 100vh;
  width: 100%;
  --tl-accent: #0e7490;
  --tl-line: #d3e3e8;
  --tl-card: #ffffff;
  --tl-bg: #f2f7f9;
  --tl-ink: #164252;
  --tl-mut: #5e7d8a;
  font-family: 'Segoe UI',system-ui,sans-serif;
  background: var(--tl-bg);
  padding: 64px 24px;
  display: grid;
  place-items: center;
}

.tls-21__frame {
  width: min(760px,100%);
}

.tls-21__title {
  font-size: clamp(22px,3.5vw,28px);
  font-weight: 800;
  color: var(--tl-ink);
  letter-spacing: -.02em;
  text-align: center;
  margin-bottom: 36px;
}

.tls-21__container {
  container-type: inline-size;
}

.tls-21__grid {
  list-style: none;
  display: grid;
  grid-template-columns: 1fr 48px 1fr;
}

.tls-21__item {
  position: relative;
  padding-bottom: 28px;
}

.tls-21__item:nth-child(odd) {
  grid-column: 1;
}

.tls-21__item:nth-child(even) {
  grid-column: 3;
}

.tls-21__item::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--tl-line);
}

.tls-21__item:nth-child(odd)::before {
  right: -25px;
}

.tls-21__item:nth-child(even)::before {
  left: -25px;
}

.tls-21__item:last-child::before {
  bottom: auto;
  height: 28px;
}

.tls-21__item::after {
  content: '';
  position: absolute;
  top: 22px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--tl-accent);
  border: 3px solid var(--tl-bg);
  z-index: 1;
}

.tls-21__item:nth-child(odd)::after {
  right: -31px;
}

.tls-21__item:nth-child(even)::after {
  left: -31px;
}

.tls-21__card {
  position: relative;
  background: var(--tl-card);
  border: 1px solid var(--tl-line);
  border-radius: 14px;
  padding: 18px 20px;
  box-shadow: 0 2px 10px -6px rgba(20,66,82,.15);
}

.tls-21__card::after {
  content: '';
  position: absolute;
  top: 22px;
  width: 12px;
  height: 12px;
  background: var(--tl-card);
  border: 1px solid var(--tl-line);
  rotate: 45deg;
}

.tls-21__item:nth-child(odd) .tls-21__card::after {
  right: -7px;
  border-left: none;
  border-bottom: none;
}

.tls-21__item:nth-child(even) .tls-21__card::after {
  left: -7px;
  border-right: none;
  border-top: none;
}

.tls-21__date {
  font-size: 12px;
  font-weight: 800;
  letter-spacing: .1em;
  color: var(--tl-accent);
}

.tls-21__h {
  font-size: 16.5px;
  font-weight: 750;
  color: var(--tl-ink);
  margin: 5px 0 5px;
  letter-spacing: -.01em;
}

.tls-21__p {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--tl-mut);
  text-wrap: pretty;
}

@container (max-width: 560px) {
  .tls-21__grid {
    grid-template-columns: 24px 1fr;
  }

  .tls-21__item:nth-child(odd),
    .tls-21__item:nth-child(even) {
    grid-column: 2;
  }

  .tls-21__item:nth-child(odd)::before,
    .tls-21__item:nth-child(even)::before {
    left: -13px;
    right: auto;
  }

  .tls-21__item:nth-child(odd)::after,
    .tls-21__item:nth-child(even)::after {
    left: -19px;
    right: auto;
  }

  .tls-21__item:nth-child(odd) .tls-21__card::after,
    .tls-21__item:nth-child(even) .tls-21__card::after {
    left: -7px;
    right: auto;
    border: 1px solid var(--tl-line);
    border-right: none;
    border-top: none;
  }
}
/* No JavaScript — container-type:inline-size on the wrapper + one @container (max-width:560px) block flips the zigzag to a single left-rail column based on the CONTAINER's width, not the viewport. */
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: Zigzag Alternating Timeline — Container Query Responsive
Source: https://codefronts.com/layouts/css-timelines/vertical-centered/

The classic center-spine zigzag — cards alternating left and right of a vertical rail with arrow notches pointing at their markers — made truly portable: it reshapes to a single left-rail column when its CONTAINER drops below 560px, not the viewport. Drop it in a sidebar, a modal or a split view and it lays itself out correctly with zero configuration.
## HTML
```html
<section class="tls-21" aria-label="Zigzag container query timeline demo">
  <div class="tls-21__frame">
    <h3 class="tls-21__title">Company milestones</h3>
    <div class="tls-21__container">
      <ol class="tls-21__grid" role="list">
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2022">2022</time>
            <h4 class="tls-21__h">Founded in a garage</h4>
            <p class="tls-21__p">Two founders, one whiteboard, no name for six weeks.</p>
          </div>
        </li>
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2023">2023</time>
            <h4 class="tls-21__h">Seed round</h4>
            <p class="tls-21__p">$2.4M to hire the first five engineers and a very good office dog.</p>
          </div>
        </li>
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2024">2024</time>
            <h4 class="tls-21__h">First enterprise customer</h4>
            <p class="tls-21__p">A 3,000-seat rollout that rewrote our definition of 'edge case'.</p>
          </div>
        </li>
        <li class="tls-21__item">
          <div class="tls-21__card">
            <time class="tls-21__date" datetime="2025">2025</time>
            <h4 class="tls-21__h">Series A</h4>
            <p class="tls-21__p">$18M led by our earliest angel. The dog got a title.</p>
          </div>
        </li>
      </ol>
    </div>
  </div>
</section>
```
## CSS
```css
.tls-21,
.tls-21 *,
.tls-21 *::before,
.tls-21 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tls-21 {
  min-height: 100vh;
  width: 100%;
  --tl-accent: #0e7490;
  --tl-line: #d3e3e8;
  --tl-card: #ffffff;
  --tl-bg: #f2f7f9;
  --tl-ink: #164252;
  --tl-mut: #5e7d8a;
  font-family: 'Segoe UI',system-ui,sans-serif;
  background: var(--tl-bg);
  padding: 64px 24px;
  display: grid;
  place-items: center;
}

.tls-21__frame {
  width: min(760px,100%);
}

.tls-21__title {
  font-size: clamp(22px,3.5vw,28px);
  font-weight: 800;
  color: var(--tl-ink);
  letter-spacing: -.02em;
  text-align: center;
  margin-bottom: 36px;
}

.tls-21__container {
  container-type: inline-size;
}

.tls-21__grid {
  list-style: none;
  display: grid;
  grid-template-columns: 1fr 48px 1fr;
}

.tls-21__item {
  position: relative;
  padding-bottom: 28px;
}

.tls-21__item:nth-child(odd) {
  grid-column: 1;
}

.tls-21__item:nth-child(even) {
  grid-column: 3;
}

.tls-21__item::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--tl-line);
}

.tls-21__item:nth-child(odd)::before {
  right: -25px;
}

.tls-21__item:nth-child(even)::before {
  left: -25px;
}

.tls-21__item:last-child::before {
  bottom: auto;
  height: 28px;
}

.tls-21__item::after {
  content: '';
  position: absolute;
  top: 22px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--tl-accent);
  border: 3px solid var(--tl-bg);
  z-index: 1;
}

.tls-21__item:nth-child(odd)::after {
  right: -31px;
}

.tls-21__item:nth-child(even)::after {
  left: -31px;
}

.tls-21__card {
  position: relative;
  background: var(--tl-card);
  border: 1px solid var(--tl-line);
  border-radius: 14px;
  padding: 18px 20px;
  box-shadow: 0 2px 10px -6px rgba(20,66,82,.15);
}

.tls-21__card::after {
  content: '';
  position: absolute;
  top: 22px;
  width: 12px;
  height: 12px;
  background: var(--tl-card);
  border: 1px solid var(--tl-line);
  rotate: 45deg;
}

.tls-21__item:nth-child(odd) .tls-21__card::after {
  right: -7px;
  border-left: none;
  border-bottom: none;
}

.tls-21__item:nth-child(even) .tls-21__card::after {
  left: -7px;
  border-right: none;
  border-top: none;
}

.tls-21__date {
  font-size: 12px;
  font-weight: 800;
  letter-spacing: .1em;
  color: var(--tl-accent);
}

.tls-21__h {
  font-size: 16.5px;
  font-weight: 750;
  color: var(--tl-ink);
  margin: 5px 0 5px;
  letter-spacing: -.01em;
}

.tls-21__p {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--tl-mut);
  text-wrap: pretty;
}

@container (max-width: 560px) {
  .tls-21__grid {
    grid-template-columns: 24px 1fr;
  }

  .tls-21__item:nth-child(odd),
    .tls-21__item:nth-child(even) {
    grid-column: 2;
  }

  .tls-21__item:nth-child(odd)::before,
    .tls-21__item:nth-child(even)::before {
    left: -13px;
    right: auto;
  }

  .tls-21__item:nth-child(odd)::after,
    .tls-21__item:nth-child(even)::after {
    left: -19px;
    right: auto;
  }

  .tls-21__item:nth-child(odd) .tls-21__card::after,
    .tls-21__item:nth-child(even) .tls-21__card::after {
    left: -7px;
    right: auto;
    border: 1px solid var(--tl-line);
    border-right: none;
    border-top: none;
  }
}
```

## JavaScript
```js
/* No JavaScript — container-type:inline-size on the wrapper + one @container (max-width:560px) block flips the zigzag to a single left-rail column based on the CONTAINER's width, not the viewport. */
```

How this works

The wrapper declares container-type: inline-size, turning it into a query container. The zigzag is one grid — grid-template-columns: 1fr 48px 1fr — where every card explicitly places itself: odd items grid-column: 1 with right-pointing notches, even items grid-column: 3 with left-pointing ones, and the spine occupies the middle track as a per-item ::before. Rows pack with grid-auto-flow: dense-free simple placement because each item also sets its own grid-row via nth-child arithmetic-free flow — items simply occupy successive rows and the empty opposite cell is the whitespace that makes zigzags breathe.

The responsive flip is a single @container (max-width: 560px) block: columns become 24px 1fr, every card snaps to column 2, notches all point left, markers slide onto the rail. Because the query watches the container, the SAME component is two-column in a 900px article body and one-column in a 320px sidebar — on the same page, at the same viewport width. That is the thing media queries fundamentally cannot do, and the reason component libraries have moved to container queries.

The notch is a rotated square (::after, 12×12, rotate:45deg) inheriting the card's background and border, clipped so only the outward corner shows — cheaper and crisper than border-triangle hacks and it follows theme tokens automatically.

Techniques used: container-type:inline-size + @container width query · 1fr auto 1fr center-spine grid with explicit per-side placement · rotated-square card notches inheriting card chrome · per-item spine segments (no measured full-height line) · design tokens for one-line theming · time[datetime] semantic dates

Make it yours

  • Breakpoint: the 560px in @container — set it to where CARDS (not the page) start feeling cramped, usually 480–640px.
  • Spine width: the middle 48px track; widen it and the markers auto-center because they're placed with justify-self.
  • Force single-column always: delete the two grid-column rules for odd/even and keep the @container block's layout as the base.
  • Test it honestly: resize the demo's PARENT (browser devtools → select wrapper → drag) — resizing the window only tests it if the wrapper tracks the viewport.

Gotchas — read before shipping

  • A query container cannot size itself from its contents in the queried axis — inline-size containment is fine for full-width wrappers, but don't put container-type on something that must shrink-wrap.
  • @container rules must target DESCENDANTS of the container, never the container itself — a container cannot query its own size.
  • Keep the spine per-item: a single absolutely-positioned full-height line needs the container's height, which changes when the layout flips, and it will desync.
  • Name containers (container-name: tl) in real projects — anonymous queries resolve to the NEAREST ancestor container, which may not be the one you meant once the component nests.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by @container, text-wrap as this demo is written. The core technique itself goes back further — Chrome 105+.

Size container queries are Baseline 2023 — safe for production. Without support the layout stays two-column zigzag (still correct, just not container-aware).

Techniques used in this demo

Search CodeFronts

Loading…