GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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). Dressing a page's opening scene in time order belongs to the hero section animations, and the button that sends readers back to the top once the story ends is the back to top button.

01Vertical line draw

What the user notices

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.

Vertical line draw
Open in a new tab

01 · Vertical line draw — Measured area 0.368% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYtransform-origin1.4s

How to wire it up

The whole effect is one transform: scaleY(0) growing to scaleY(1) with transform-origin: top.

.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

Where this shows up

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.

Dot stagger pop
Open in a new tab

02 · Dot stagger pop — Measured area 4.049% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleanimation-delaynth-child

The core of it

Every dot shares one keyframe; only animation-delay differs, generated per nth-child by an SCSS @for loop instead of nine hand-written rules.

.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

Where a css stepper animation fits

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.

Horizontal steps fill
Open in a new tab

03 · Horizontal steps fill — Measured area 1.597% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXflex: 1 1 0600ms

The idea behind it

scaleX grows against the connector's own width, so neither step needs to know how wide its neighbor is.

.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

What it looks like

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.

Active step pulse
Open in a new tab

04 · Active step pulse — Measured area 2.033% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleopacity1.6s

Why it is done this way

.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

When to use it

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.

Alternating card slide
Open in a new tab

05 · Alternating card slide — Measured area 8.283% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXopacity500ms

Worth noting

Each side gets its own keyframe rather than one shared one, which turns out to matter — the trap section below explains why.

.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

What you see

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.

Checkout stepper
Open in a new tab

06 · Checkout stepper — Measured area 0.77% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetscaleXSVG

The key property

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.

.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())

Where it fits

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

Scroll-drawn progress line (scroll())
Open in a new tab

07 · Scroll-drawn progress line (scroll()) — Measured area 0.373% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-timeline: scroll()scaleYJS 0줄

What to read in the code

.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()

What moves

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.

Events reveal via view()
Open in a new tab

08 · Events reveal via view() — Measured area 29.524% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-timeline: view()animation-rangeJS 0줄

How it is built

.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()

The moment it fires

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.

Auto numbering via sibling-index()
Open in a new tab

09 · Auto numbering via sibling-index() — Measured area 4.478% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

sibling-index()counter-setcalc()

How it works

animation-delay is computed from that same sibling-index(), so the stagger stays correct too.

.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?

What goes wrong

The real bug in this set wasn't a css animation timeline scroll 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.

How to avoid it

The confusion that follows from never counting where the visitor has reached is rewritten with spotlight grammar in the onboarding tour widgets, which point at one step at a time.

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

What reduced motion leaves

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").

Related reading

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 where css animation timeline browser support is missing?

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. When the dates themselves have to spread across a grid, the CSS calendar design patterns hold the month-view rules.

Enter the archive password

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