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.

  • 235 lines of CSS
  • 29 lines of HTML
  • 19 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Every card stands one step further back: translateZ(calc(var(--i) * -110 * var(--u))), 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, 1 + d·4, 1): 1 at the front, 0 as soon as a card is passed. Distance is shown with filter: brightness(clamp(0.8, 1 − d·0.1, 1)) instead of a fade, and never goes under 0.8, so the words on a far card still read. 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 (22 units) divides the step (110), 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% -80 units), so cards further away climb up the view instead of hiding behind the ones in front.
  6. Every length is a multiple of one base unit, --u, so the road is the same share of a gallery card, the editor and a recording canvas. JS writes only --a, a plain number; the caption and the buttons under the road are in plain vmin, the same control zone as every other model’s.

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 29 lines

<div class="timeline">
  <div class="view">
  <div class="scene" 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>
  </div>
  <div class="controls">
    <output class="caption">2016 · First sketch</output>
    <div class="row">
      <button type="button" data-step="-1" disabled>‹ Prev</button>
      <button type="button" data-step="1">Next ›</button>
    </div>
  </div>
</div>

CSS 235 lines

/* the model box and the control zone stand in one stack, so the zone is the same distance
   under the road in every model */
.timeline {
  /* one base unit: every length of the road and the cards is a multiple of it. The control
     zone is in plain vmin, because it is the same object in every model. */
  --u: 0.24vmin;
  display: grid;
  justify-items: center;
  gap: 4vmin;
  font-family: Inter, system-ui, sans-serif;
}

/* the model box */
.view {
  display: grid;
  place-items: center;
  height: 50vmin;
}

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

/* the camera: close, and high above the road */
.scene {
  position: relative;
  width: calc(380 * var(--u));
  height: calc(200 * var(--u));
  perspective: calc(560 * var(--u));
  perspective-origin: 50% calc(-80 * var(--u));
}

.track {
  position: absolute;
  left: 50%;
  top: 62%;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--a) * 110 * var(--u))); /* 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% - calc(48 * var(--u)));
  top: calc(62% - calc(684 * var(--u))); /* its near edge 36 units below the cards' centre */
  width: calc(96 * var(--u));
  height: calc(720 * var(--u)); /* it has faded out by its far end, so it stops there */
  overflow: hidden;
  background:
    linear-gradient(90deg, transparent calc(3 * var(--u)), #8b6cff calc(3 * var(--u)) calc(5 * var(--u)), transparent calc(5 * var(--u)) calc(100% - calc(5 * var(--u))), #8b6cff calc(100% - calc(5 * var(--u))) calc(100% - calc(3 * var(--u))), transparent 0),
    #272551;
  mask: linear-gradient(to top, transparent, #000 8.75%, #000 50%, transparent);
  transform-origin: 50% 100%;
  transform: translateZ(calc(64 * var(--u))) rotateX(90deg);
}

/* dashes and markers: longer than the road by the whole trip; they slide, the road does not */
.lane {
  position: absolute;
  inset: calc(-440 * var(--u)) 0 0;
  background: linear-gradient(rgb(255 181 71 / 0.8) 50%, transparent 0) 50% 100% / calc(3 * var(--u)) calc(22 * var(--u)) repeat-y;
  transform: translateY(calc(var(--a) * 110 * var(--u)));
  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% - calc(7 * var(--u)));
  bottom: calc(calc(57 * var(--u)) + var(--i) * 110 * var(--u));
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  border: calc(2 * var(--u)) solid #2ee6d6;
  border-radius: 50%;
  background: #141830;
}

/* lit only where d = 0: 1 - min(|d|, 1) */
.lane u::after {
  content: '';
  position: absolute;
  inset: calc(1 * var(--u));
  border-radius: 50%;
  background: #2ee6d6;
  box-shadow: 0 0 calc(8 * var(--u)) #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: calc(-62 * var(--u));
  top: calc(-62 * var(--u));
  display: grid;
  align-content: center;
  gap: calc(2 * var(--u));
  width: calc(124 * var(--u));
  height: calc(66 * var(--u));
  padding: 0 calc(15 * var(--u));
  border: calc(1 * var(--u)) solid rgb(140 150 220 / 0.34);
  border-radius: calc(10 * var(--u));
  background: linear-gradient(160deg, #3d3576, #202045);
  color: #eceefb;
  backface-visibility: hidden;
  /* darker further back (brightness, not opacity: a see-through card went pale on the light stage
     and its words with it, 1.3:1), never under 0.8, so every card's words still read; gone once
     passed */
  filter: brightness(clamp(0.8, 1 - var(--d) * 0.1, 1));
  opacity: clamp(0, 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) * 112 * var(--u)), 0, calc((var(--i) * -1 + min(var(--d) + 1, 0)) * 110 * var(--u)))
    rotateY(calc(var(--s) * -16deg));
  transition: opacity 0.6s, filter 0.6s, transform 0.9s cubic-bezier(0.45, 0.05, 0.25, 1), border-color 0.4s;
}

.card b {
  font-size: calc(25 * var(--u));
  font-weight: 900;
  line-height: 1;
}

.card span {
  /* #bec2e0, not #b8bddd: the cards further back are dimmed with brightness, and at that dimming
     the old colour measured 4.35:1 against the card behind it, under the 4.5:1 AA asks for. This
     lands at 4.58:1 dimmed, and is still lighter than the muted #949bc0 used elsewhere. */
  color: #bec2e0;
  font-size: calc(15 * var(--u));
  font-weight: 700;
  white-space: nowrap;
}

/* the post down to the road */
.card::before {
  content: '';
  position: absolute;
  left: calc(50% - calc(1 * var(--u)));
  top: 100%;
  width: calc(2 * var(--u));
  height: calc(32 * var(--u));
  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;
}

/* the control zone: the same object, at the same size, in every model that has one. The
   caption's line box never changes height, so a new event cannot move the road. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  opacity: 0.7;
}

.controls .row {
  display: flex;
  gap: 2vmin;
}

.controls button,
.controls label {
  height: 8vmin;
  min-width: 8vmin;
  padding: 0 3vmin;
  box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
  border: 0;
  border-radius: 999px;
  /* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
     strength: no colour of the model's, which would vanish on one of the two stages */
  background: rgb(140 150 220 / 0.2);
  color: inherit;
  font: 600 4vmin Inter, system-ui, sans-serif;
  cursor: pointer;
  transition: background-color 0.35s;
}

.controls button:hover,
.controls label:hover {
  background-color: rgb(140 150 220 / 0.34);
}

.controls :focus-visible {
  outline: 0.6vmin solid #6a45f5;
  outline-offset: 0.6vmin;
}

/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
  background: linear-gradient(135deg, #6a45f5, #d1206f);
  color: #fff;
}

/* The pill already chosen answers the pointer too.

   Hover sets background-color; this rule sets the background SHORTHAND, so its gradient image was
   painted straight over that colour. The one control already selected was the one control that
   did nothing when you pointed at it, which is what check-motion kept saying:
   'hover button#1 "Low": changes nothing on screen'.

   Darker, not lighter: the gradient above is as pale as it can be with white text still passing. */
.controls [aria-pressed='true']:hover {
  background-image: linear-gradient(135deg, #5730dd, #b31859);
}

/* Next is the way on, so it wears the selected pill's gradient. The gradient hides the hover
   tint, so pointed at it brightens instead */
.controls button:last-child:not(:disabled) {
  background: linear-gradient(135deg, #6a45f5, #d1206f);
  color: #fff;
  transition: background-color 0.35s, filter 0.35s;
}

.controls button:last-child:not(:disabled):hover {
  filter: brightness(0.88); /* darker, not lighter: brightened, its white word was 4.3:1 */
}

/* a button that does nothing does not light up when pointed at */
.controls button:disabled {
  opacity: 0.4;
  background-color: rgb(140 150 220 / 0.2);
  cursor: default;
}

JS 19 lines

const scene = document.querySelector('.timeline .scene');
const cards = document.querySelectorAll('.card');
const out = document.querySelector('.controls output');
const [prev, next] = document.querySelectorAll('.controls 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
  scene.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));
Standalone snippet · plain CSS, no build step, no dependencies.