CSS + JS Data & tools #controls #data #timeline #scene

3D timeline in CSS + JavaScript

Five event cards stand beside a road, each a step further back in Z. Prev / Next only change one number, --a; the whole track slides toward you by that many steps, so the active card comes to the front.

  • 165 lines of CSS
  • 25 lines of HTML
  • 19 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. Every card stands one step further back: translateZ(calc(var(--i) * -110px)), alternating sides of the road with --s = -1 / 1 and turned a little toward it.
  2. JS sets one number, --a (the active card), on the view. The track of cards moves toward you by --a steps, which puts card --a at z = 0: moving the world is moving the camera. A transition does the drive.
  3. Each card works out its own --d = --i − --a. Opacity is clamp(0, min(1 − d·0.2, 1 + d·4), 1): 1 at the front, fading with distance, 0 as soon as a card is passed. Passed cards also get min(d + 1, 0) extra steps back, so nothing ever gets closer than one step to the camera.
  4. The road does not travel (its near end would run into the camera). Only its lane of dashes and markers slides. The dash period (22px) divides the step (110px), so at every stop the road looks the same. The marker under the active card lights up with opacity: 1 − min(|d|, 1), written with max(d, -d).
  5. The camera sits high above (perspective-origin: 50% -80px), so cards further away climb up the view instead of hiding behind the ones in front.

Key techniques

  • translateZ(--i × -step) per card
  • track translateZ(--a × step) = camera move
  • opacity from --i − --a with clamp()
  • passed cards held back and faded

Copy-paste code

HTML 25 lines

<div class="timeline">
  <div class="view" style="--a:0">
    <div class="road">
      <div class="lane">
        <u style="--i:0"></u>
        <u style="--i:1"></u>
        <u style="--i:2"></u>
        <u style="--i:3"></u>
        <u style="--i:4"></u>
      </div>
    </div>
    <div class="track">
      <div class="card" style="--i:0;--s:-1" aria-current="true"><b>2016</b><span>First sketch</span></div>
      <div class="card" style="--i:1;--s:1" aria-current="false"><b>2019</b><span>Launch</span></div>
      <div class="card" style="--i:2;--s:-1" aria-current="false"><b>2021</b><span>Version 2</span></div>
      <div class="card" style="--i:3;--s:1" aria-current="false"><b>2023</b><span>Going global</span></div>
      <div class="card" style="--i:4;--s:-1" aria-current="false"><b>2026</b><span>Today</span></div>
    </div>
  </div>
  <output>2016 · First sketch</output>
  <div class="nav">
    <button type="button" data-step="-1" disabled>‹ Prev</button>
    <button type="button" data-step="1">Next ›</button>
  </div>
</div>

CSS 165 lines

.timeline {
  display: grid;
  justify-items: center;
  gap: 8px;
  font-family: system-ui, sans-serif;
}

.timeline * {
  box-sizing: border-box;
}

/* the camera: close, and high above the road */
.view {
  position: relative;
  width: 340px;
  height: 200px;
  perspective: 560px;
  perspective-origin: 50% -80px;
}

.track {
  position: absolute;
  left: 50%;
  top: 62%;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--a) * 110px)); /* the drive */
  transition: transform 0.9s cubic-bezier(0.45, 0.05, 0.25, 1);
}

/* the road: stood on its near edge, laid back flat; it stays put */
.road {
  position: absolute;
  left: calc(50% - 34px);
  top: calc(62% - 864px); /* its near edge 36px below the cards' centre */
  width: 68px;
  height: 900px;
  overflow: hidden;
  background:
    linear-gradient(90deg, transparent 3px, #8b6cff 3px 5px, transparent 5px calc(100% - 5px), #8b6cff calc(100% - 5px) calc(100% - 3px), transparent 0),
    #272551;
  mask: linear-gradient(to top, transparent, #000 7%, #000 40%, transparent 80%);
  transform-origin: 50% 100%;
  transform: translateZ(64px) rotateX(90deg);
}

/* dashes and markers: longer than the road by the whole trip; they slide, the road does not */
.lane {
  position: absolute;
  inset: -440px 0 0;
  background: linear-gradient(rgb(255 181 71 / 0.8) 50%, transparent 0) 50% 100% / 3px 22px repeat-y;
  transform: translateY(calc(var(--a) * 110px));
  transition: transform 0.9s cubic-bezier(0.45, 0.05, 0.25, 1);
}

.lane u {
  --d: calc(var(--i) - var(--a));
  position: absolute;
  left: calc(50% - 7px);
  bottom: calc(57px + var(--i) * 110px);
  width: 14px;
  height: 14px;
  border: 2px solid #2ee6d6;
  border-radius: 50%;
  background: #141830;
}

/* lit only where d = 0: 1 - min(|d|, 1) */
.lane u::after {
  content: '';
  position: absolute;
  inset: 1px;
  border-radius: 50%;
  background: #2ee6d6;
  box-shadow: 0 0 8px #2ee6d6;
  opacity: calc(1 - min(max(var(--d), var(--d) * -1), 1));
  transition: opacity 0.5s;
}

.card {
  --d: calc(var(--i) - var(--a)); /* steps from the active card */
  position: absolute;
  left: -50px;
  top: -52px;
  display: grid;
  align-content: center;
  gap: 2px;
  width: 100px;
  height: 56px;
  padding: 0 12px;
  border: 1px solid rgb(140 150 220 / 0.34);
  border-radius: 10px;
  background: linear-gradient(160deg, #3d3576, #202045);
  color: #eceefb;
  backface-visibility: hidden;
  /* 1 in front, fainter further back, 0 once passed */
  opacity: clamp(0, min(1 - var(--d) * 0.2, 1 + var(--d) * 4), 1);
  /* beside the road, one step back per index; passed cards held one step ahead of the camera */
  transform:
    translate3d(calc(var(--s) * 62px), 0, calc((var(--i) * -1 + min(var(--d) + 1, 0)) * 110px))
    rotateY(calc(var(--s) * -16deg));
  transition: opacity 0.6s, transform 0.9s cubic-bezier(0.45, 0.05, 0.25, 1), border-color 0.4s;
}

.card b {
  font-size: 20px;
  font-weight: 900;
  line-height: 1;
}

.card span {
  color: #949bc0;
  font-size: 10px;
  font-weight: 700;
  white-space: nowrap;
}

/* the post down to the road */
.card::before {
  content: '';
  position: absolute;
  left: calc(50% - 1px);
  top: 100%;
  width: 2px;
  height: 32px;
  background: linear-gradient(rgb(140 150 220 / 0.34), rgb(139 108 255 / 0.6));
}

.card[aria-current='true'] {
  border-color: #2ee6d6;
}

.card[aria-current='true'] b {
  color: #2ee6d6;
}

output {
  color: #949bc0;
  font-size: 14px;
  font-weight: 600;
}

.nav {
  display: flex;
  gap: 8px;
}

.nav button {
  padding: 6px 16px;
  border: 1px solid rgb(140 150 220 / 0.34);
  border-radius: 999px;
  background: transparent;
  color: #eceefb;
  font: 700 14px system-ui, sans-serif;
  cursor: pointer;
}

.nav button:last-child:not(:disabled) {
  border-color: transparent;
  background: linear-gradient(135deg, #8b6cff, #ff4d9d);
}

.nav button:disabled {
  opacity: 0.4;
  cursor: default;
}

JS 19 lines

const view = document.querySelector('.view');
const cards = document.querySelectorAll('.card');
const out = document.querySelector('output');
const [prev, next] = document.querySelectorAll('.nav button');
let active = 0;

function go(step) {
  active = Math.min(cards.length - 1, Math.max(0, active + step));
  // one number moves the camera; CSS works out every card's depth and fade from it
  view.style.setProperty('--a', active);
  cards.forEach((c, i) => c.setAttribute('aria-current', String(i === active)));
  const card = cards[active];
  out.textContent = card.querySelector('b').textContent + ' · ' + card.querySelector('span').textContent;
  prev.disabled = active === 0;
  next.disabled = active === cards.length - 1;
}

prev.addEventListener('click', () => go(-1));
next.addEventListener('click', () => go(1));

Sass source 195 lines

// Every card stands $step further back than the one before: translateZ(--i · -$step). JS sets one
// number on the view, --a (the active card). The track of cards moves toward you by --a steps,
// so card --a lands at z = 0, the front. Each card reads --d = --i − --a: cards ahead fade with
// distance, the one just passed fades out, and older ones are held back one step behind it, so
// nothing ever comes closer than one step to the camera.
//
// The road does not travel with the cards (its near end would run into the camera and over the
// controls). It stays put and only its lane layer (dashes + markers) slides. The dash period
// (22px) divides the step (110px), so at every stop the road looks exactly the same.
$step: 110px;
$floor: 36px; // the road, below the track's centre
$near: 64px; // the road starts this far in front of the front card
$len: 900px;
$ease: 0.9s cubic-bezier(0.45, 0.05, 0.25, 1);

.d-timeline {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px; // the control dock: the same place in every demo with controls

  // Its own camera: close, and looking down the road from above. Road and track are separate
  // 3D children, painted in order: the cards stand above the road, so they always go on top.
  &__view {
    position: relative;
    min-height: 0;
    perspective: 560px;
    perspective-origin: 50% -80px; // high above: the cards further away climb up the view, clear of the ones in front
  }

  &__track {
    position: absolute;
    left: 50%;
    top: 62%;
    transform-style: preserve-3d;
    transform: translateZ(calc(var(--a) * #{$step}));
    transition: transform $ease;
  }

  // The road: one long plane stood on its near edge, then laid back flat with rotateX(90deg)
  // around that edge. It may clip and mask: it is flat, nothing on it is 3D.
  &__road {
    position: absolute;
    left: calc(50% - 34px);
    top: calc(62% + #{$floor - $len});
    width: 68px;
    height: $len;
    overflow: hidden;
    background:
      linear-gradient(90deg, transparent 3px, var(--accent) 3px 5px, transparent 5px calc(100% - 5px), var(--accent) calc(100% - 5px) calc(100% - 3px), transparent 0),
      color-mix(in srgb, var(--accent) 16%, var(--surface-solid));
    mask: linear-gradient(to top, transparent, #000 7%, #000 40%, transparent 80%);
    transform-origin: 50% 100%;
    transform: translateZ($near) rotateX(90deg);
  }

  // dashes and markers: longer than the road by the whole trip, slid toward you by --a steps
  &__lane {
    position: absolute;
    inset: ($step * -4) 0 0;
    background: linear-gradient(color-mix(in srgb, var(--warm) 80%, transparent) 50%, transparent 0) 50% 100% / 3px 22px repeat-y;
    transform: translateY(calc(var(--a) * #{$step}));
    transition: transform $ease;

    // a marker on the road for every event, bright at the active one
    u {
      --d: calc(var(--i) - var(--a));
      position: absolute;
      left: calc(50% - 7px);
      bottom: calc(#{$near - 7px} + var(--i) * #{$step});
      width: 14px;
      height: 14px;
      border: 2px solid var(--accent-2);
      border-radius: 50%;
      background: var(--surface-solid);

      // the fill lights up only where --d is 0: opacity 1 - min(|d|, 1)
      &::after {
        content: '';
        position: absolute;
        inset: 1px;
        border-radius: 50%;
        background: var(--accent-2);
        box-shadow: 0 0 8px var(--accent-2);
        opacity: calc(1 - min(max(var(--d), var(--d) * -1), 1));
        transition: opacity 0.5s;
      }
    }
  }

  &__card {
    --d: calc(var(--i) - var(--a));
    position: absolute;
    left: -50px;
    top: -52px;
    display: grid;
    align-content: center;
    gap: 2px;
    width: 100px;
    height: 56px;
    padding: 0 12px;
    border: 1px solid var(--border-strong);
    border-radius: 10px;
    background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 34%, var(--surface-solid)), color-mix(in srgb, var(--accent) 10%, var(--surface-solid)));
    color: var(--text);
    backface-visibility: hidden;
    // 1 at the front, fading 0.2 a step behind it; 0 as soon as it is passed (the second min)
    opacity: clamp(0, min(1 - var(--d) * 0.2, 1 + var(--d) * 4), 1);
    // Beside the road (--s: -1 left, 1 right), turned a little toward it, one step back per
    // index. Passed cards get min(--d + 1, 0) extra steps back, so they stay one step ahead
    // of the camera at most.
    transform:
      translate3d(calc(var(--s) * 62px), 0, calc((var(--i) * -1 + min(var(--d) + 1, 0)) * #{$step}))
      rotateY(calc(var(--s) * -16deg));
    transition:
      opacity 0.6s,
      transform $ease,
      border-color 0.4s;

    b {
      font-size: 20px;
      font-weight: 900;
      line-height: 1;
      letter-spacing: -0.02em;
      transition: color 0.4s;
    }

    span {
      color: var(--muted);
      font-size: 10px;
      font-weight: 700;
      white-space: nowrap;
    }

    // the post down to the road
    &::before {
      content: '';
      position: absolute;
      left: calc(50% - 1px);
      top: 100%;
      width: 2px;
      height: $floor - 4px;
      background: linear-gradient(var(--border-strong), color-mix(in srgb, var(--accent) 60%, transparent));
    }

    &[aria-current='true'] {
      border-color: var(--accent-2);

      b {
        color: var(--accent-2);
      }
    }
  }

  &__bar {
    display: grid;
    justify-items: center;
    gap: 6px;
    font-size: 12px;

    output {
      color: var(--muted);
      font-weight: 600;
      white-space: nowrap;
    }
  }

  &__nav {
    display: flex;
    gap: 8px;

    button {
      padding: 5px 14px;
      border: 1px solid var(--border-strong);
      border-radius: 999px;
      background: color-mix(in srgb, var(--bg) 70%, transparent);
      color: var(--text);
      font: inherit;
      font-weight: 700;
      cursor: pointer;

      &:last-child:not(:disabled) {
        border-color: transparent;
        background: linear-gradient(135deg, var(--accent), var(--hot));
        color: #fff;
      }

      &:disabled {
        opacity: 0.4;
        cursor: default;
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.