GODRICH

9 CSS Timeline Animation Ideas, Copy-Paste

A CSS timeline animation is a line of dots, bars, or cards that draws or fills itself to show steps in order — a company's history, an order's four

Auto-plays · click a tile to jump to its section · all nine in one zip

These nine aren't ranked by how flashy they look — they follow the order a timeline actually gets built in. First come the two ways the line itself gets drawn (01–02), then two ways a horizontal stepper shows progress (03–04), then two ways cards or checks layer onto content already on the page (05–06), and last, three that hand scroll position and list order straight to CSS instead of a script (07–09).

01Vertical line draw

Scroll down to a vertical timeline — a company history, a résumé's work history — and instead of the line just sitting there, it grows from top to bottom like a pen stroke as the list comes into view. The whole effect is one transform: scaleY(0) growing to scaleY(1) with transform-origin: top.

scaleYtransform-origin1.4s
.rail {
  width: 3px;
  height: 100%;
  background: $color;
  transform-origin: top;
  transform: scaleY(1);

  @supports (animation-timeline: view()) {
    transform: scaleY(0);
    animation: draw linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 70%;
  }
}
@keyframes draw {
  from { transform: scaleY(0); }
  to   { transform: scaleY(1); }
}

02Dot stagger pop

The dots sitting along that line pop in one after another from top to bottom, each one bouncing past full size before it settles — a fit for onboarding checklists or order-status steps. Every dot shares one keyframe; only animation-delay differs, generated per nth-child by an SCSS @for loop instead of nine hand-written rules.

scaleanimation-delaynth-child
.dot {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: $color;
  transform: scale(0);
  opacity: 0;
  animation: pop 2s cubic-bezier(.2, 1.4, .4, 1) both;
}

@for $i from 1 through 5 {
  .dot:nth-child(#{$i}) { animation-delay: #{($i - 1) * 140}ms; }
}

@keyframes pop {
  0%   { transform: scale(0);    opacity: 0; }
  60%  { transform: scale(1.25); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}

03Horizontal steps fill

In a sign-up flow or survey laid out left to right, the connector between two steps fills in color the moment the step before it finishes, so one glance at the bar shows exactly how far someone got. scaleX grows against the connector's own width, so neither step needs to know how wide its neighbor is.

scaleXflex: 1 1 0600ms
.bar {
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 0;
  height: 3px;
  overflow: hidden;
}
.fill {
  width: 100%;
  height: 100%;
  background: $color;
  transform: scaleX(0);
  transform-origin: left;
}
.step.is-done + .bar .fill { transform: scaleX(1); }

04Active step pulse

Behind the step currently being worked on — an order being packed, a live notification badge — a ring positioned behind the circle expands outward and fades on a loop, reading like a radar ping that marks "this one, right now." It's a separate .ring element sitting behind the circle, not a pseudo-element on the circle itself, so the number inside stays sharp while only the ring fades and grows.

scaleopacity1.6s
.ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid $color;
  animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
  0%   { transform: scale(.7); opacity: .8; }
  70%  { transform: scale(1.8); opacity: 0; }
  100% { transform: scale(1.8); opacity: 0; }
}

05Alternating card slide

On a project-milestone page or a company-history layout, cards line up on alternating sides of a center rail and slide in as each one scrolls into view — odd cards arriving from the left, even cards from the right, fading in at the same time. Each side gets its own keyframe rather than one shared one, which turns out to matter — the trap section below explains why.

translateXopacity500ms
.row--l .card { transform: translateX(-24px); }
.row--r .card { transform: translateX(24px); }

@supports (animation-timeline: view()) {
  .row--l .card { animation: in-l ease-out both; animation-timeline: view(); animation-range: entry 0% cover 60%; }
  .row--r .card { animation: in-r ease-out both; animation-timeline: view(); animation-range: entry 0% cover 60%; }
}
@keyframes in-l { from { opacity: 0; transform: translateX(-24px); } to { opacity: 1; transform: translateX(0); } }
@keyframes in-r { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } }

06Checkout stepper

In a four-step checkout or booking form, a finished step swaps its number for a checkmark that draws itself stroke by stroke, and the connector to the next step fills in right after. The check is a real SVG polyline animated with stroke-dashoffset, not two rotated bars, so there's no seam where the strokes could land out of alignment.

stroke-dashoffsetscaleXSVG
.check polyline {
  stroke-dasharray: 16;
  stroke-dashoffset: 16;
}
.step.is-checked .check polyline {
  animation: draw .5s ease-out forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }

.step.is-checked + .bar .fill { transform: scaleX(1); }

07Scroll-drawn progress line (scroll())

Inside a long article's side rail or a scrollable timeline box, the vertical line draws itself in exact lockstep with how far the reader has scrolled — no scroll listener, no requestAnimationFrame loop, just one declaration: animation-timeline: scroll(nearest block).

animation-timeline: scroll()scaleYJS 0줄
.rail {
  width: 3px;
  height: 100%;
  background: $color;
  transform-origin: top;
  transform: scaleY(1);
}
@supports (animation-timeline: scroll()) {
  .rail { transform: scaleY(0); animation: draw linear both; animation-timeline: scroll(nearest block); }
}
@supports not (animation-timeline: scroll()) {
  .rail { transform: scaleY(1); }
}
@keyframes draw { to { transform: scaleY(1); } }

08Events reveal via view()

In a scroll-revealed event timeline or a long-form article's section breaks, each card sets its own animation-timeline: view() with an animation-range, so opacity and translateY move automatically as that one card crosses into the viewport — no IntersectionObserver, no per-card JS at all.

animation-timeline: view()animation-rangeJS 0줄
.card {
  opacity: 1;
}
@supports (animation-timeline: view()) {
  .card {
    opacity: 0;
    transform: translateY(16px);
    animation: reveal ease-out both;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }
}
@supports not (animation-timeline: view()) {
  .card { opacity: 1; transform: none; }
}
@keyframes reveal { to { opacity: 1; transform: translateY(0); } }

09Auto numbering via sibling-index()

In a drag-to-reorder checklist, or any step list where items get added and removed often, each step sets counter-set: n calc(sibling-index()) so its ::before content numbers itself off the DOM's actual sibling order — no nth-child rule to rewrite every time the list changes. animation-delay is computed from that same sibling-index(), so the stagger stays correct too.

sibling-index()counter-setcalc()
.step {
  counter-increment: n;
  &::before { content: counter(n); }
}
@supports (z-index: calc(sibling-index())) {
  .step {
    counter-increment: none;
    counter-set: n calc(sibling-index());
    animation-delay: calc((sibling-index() - 1) * 120ms);
  }
}

Where does this break?

The real bug in this set wasn't a browser-support gap — it was a typo that left item 05 invisible in actual use while its own demo gif looked completely normal. The live @supports (animation-timeline: view()) rule pointed every card at one keyframe name, in, that was never defined anywhere in the file; only in-l and in-r existed. A browser given an animation-name that resolves to nothing just skips that rule, so .card's base opacity: 0 never got overridden outside the demo's own looping animation, which referenced in-l/in-r directly and kept working regardless. That gap survived all the way into a packaged zip before a second read of the actual code — not the render script, not the gif — caught it; splitting the rule so each side points at its own keyframe — the code shown above — is the real fix. Two more items had a gap worth naming honestly: 07 and 08 originally wrote their moving rule only inside @supports (animation-timeline: scroll()) / view(), with no opposite branch, so on Firefox or Safari the rail sat stuck at scaleY(0) and the cards sat stuck at opacity: 0 — invisible, not just unanimated. Both now carry an @supports not branch that locks in the fully-drawn or fully-revealed state instead — the same fallback shown in their code above. Support for the underlying timelines, measured 2026-09-10 against caniuse and MDN's animation-timeline reference: Chrome and Edge have shipped animation-timeline: scroll()/view() since version 115, Safari added it in version 26 (2025-09), and Firefox still keeps it behind a flag as of version 158; sibling-index() is newer still, landing in Chrome 129 with Safari and Firefox not yet supporting it. The nine files linked below already carry every fix described here, and the zip that holds them opens with a password sitting in this sentence as a plain word, no box around it, nothing to click: q3b8jjma.

Feature Chrome / Edge Safari Firefox
animation-timeline: scroll() / view() 115+ 26+ (2025-09) 158+, flagged
sibling-index() / sibling-count() 129+ not yet not yet

Accessibility

All nine stop moving under prefers-reduced-motion: reduce, but what's left on screen differs by what each one was doing. The ones that draw or fill — 01, 03, 06, 07, 08 — snap straight to their fully-drawn end state instead of freezing partway, so a reduced-motion visitor never lands on a half-finished line or a stepper stuck on step one. The ones that pop or slide in — 02, 05, 09 — appear already settled, every dot and card in its resting place. Item 04's ring keeps a fixed opacity: .6 with no scale change at all, so the "this step is active" signal survives without ever pulsing. Two items need a hand-written aria-label because their visible mark never reaches the accessibility tree on its own: item 06's checkmark is state, not decoration, so the circle carries aria-label="Done" (with the SVG itself marked aria-hidden) rather than leaving a screen reader with nothing to announce where a sighted visitor sees "done." Item 09 needs the same treatment for a different reason — a counter() value rendered through ::before doesn't reach most browsers' accessibility tree, so without help a screen reader would read a step's own text ("Shipping") but never the "2" in front of it; sibling-index() fixes the visible number, not that gap, so each <li> carries its own aria-label naming the step's position ("2nd step: Shipping").

The same scroll-driven trigger runs a related set in 9 CSS Scroll Progress Bar Ideas, and the rest of this site's CSS animation collections sit under the CSS category.

FAQ

Does the timeline still work in browsers that don't support scroll() or view()?

Yes. Items 07 and 08 both carry an @supports not (animation-timeline: scroll()) (item 08 uses view()) branch that locks the rail or the cards into their fully-drawn state instead of leaving anything invisible, and the vanilla/ and react/ folders in the zip behave the same way. Making 07 or 08 track live scroll position in Firefox or Safari today would take a hand-written scroll or IntersectionObserver listener writing a CSS variable — there's no CSS-only shortcut around that yet.

Why use sibling-index() instead of just nth-child for numbering?

nth-child is enough for a list that never changes shape. sibling-index() earns its place once the list doesn't stay put — a drag-to-reorder checklist, or steps that get added and removed — because it reads the DOM's current sibling order directly instead of needing one hand-written rule per position that has to be rewritten whenever the count changes.

Can the vertical and horizontal styles go on the same page?

They usually cover different jobs rather than compete for the same one — 01, 02, 05, 07, and 08 read top to bottom and suit page content like a history or an event list, while 03, 04, and 06 read left to right and suit a form's progress indicator. Mixing directions inside one section tends to blur which one shows "current progress," so keeping them in separate regions of the page reads more clearly.

Enter the archive password

The password is inside this article. You will find it as you read.