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));