Creating Staggered Animations Using animation-delay and CSS Custom Properties

CSS has no stagger() function. Create staggered animations with animation-delay, custom properties and nth-child to cascade start times across a list of elements.

You are staring at a row of five cards that all fade in at the same instant, and it looks flat. The fix is not a JavaScript stagger function. CSS has no native one. What it does have is a pattern that combines animation-delay with a custom property set on each child via nth-child and calc. This guide shows you how to build a cascading sequence where every element starts after the previous one, using only declarative rules. Staggered entrance effects in CSS are a solved problem once you understand the two moving parts: a per-item delay value and animation-fill-mode: backwards to prevent the pre-animation flash.

The Technique: animation-delay with a Custom Property and nth-child

CSS animations run on a timeline that starts when the element is rendered. To offset them, give each item a different animation-delay. Writing .item:nth-child(1) { animation-delay: 0.1s; } and so on for every child is verbose and breaks the moment you add an item. The cleaner route sets a custom property --item-index on each child and lets calc compute the delay: --item-index: 1 on the first, 2 on the second, up to n. Then one rule handles the whole list: animation-delay: calc(var(--item-index) * 0.1s). The calculation multiplies the index by a stagger interval, producing a uniform cascade without a single repeated delay value.

Why the Custom Property Inherits and Cascade Matters

Custom properties inherit by default, which is both a gift and a trap. If you set --item-index on the parent, every child inherits the same value and the stagger collapses. The correct placement is on each child element itself, via nth-child selectors that assign the integer. The cascade then carries that value into the calc() expression at animation time. Because custom properties participate in the cascade, you can override --item-index for a specific child later, say to slow down the third item, without touching the animation-delay rule. This is the declarative constraint-solving that makes the technique feel like a system rather than a list of exceptions.

A List Where Each Item Fades In

Building the Entrance Effect

The first sample is a vertical list of five items. Each item fades in and slides up slightly, with a delay that grows by 0.15s per index. The motion uses transform and opacity, both compositor-safe properties, so the browser can run the work on the compositor thread rather than the main thread.

.list-item {
  --item-index: 0;
  opacity: 0;
  animation: fade-in 0.6s ease-out both;
  animation-delay: calc(var(--item-index) * 0.15s);
}

.list-item:nth-child(1) { --item-index: 0; }
.list-item:nth-child(2) { --item-index: 1; }
.list-item:nth-child(3) { --item-index: 2; }
.list-item:nth-child(4) { --item-index: 3; }
.list-item:nth-child(5) { --item-index: 4; }

@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(1rem);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .list-item {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Avoiding the Flash and Respecting Motion Preferences

The animation-fill-mode: both in the shorthand ensures the element stays at zero visibility during the delay period. Without it, or without the explicit backwards value, the element shows its natural state before the motion begins, causing a flash. This is the common mistake that makes the offset look broken. The media query for prefers-reduced-motion strips the motion entirely and forces the final state, which is the accessibility baseline.

A Gallery Where Cards Scale Up in Sequence

The same pattern extends to a grid of cards. Here the delay interval is shorter, 0.08s, because the eye reads a grid row by row and a long gap between cards feels hesitant. The effect scales from 0.9 to 1 and fades in, again using transform and opacity only.

.card {
  --item-index: 0;
  animation: scale-in 0.4s ease-out backwards;
  animation-delay: calc(var(--item-index) * 0.08s);
}

.card:nth-child(1) { --item-index: 0; }
.card:nth-child(2) { --item-index: 1; }
.card:nth-child(3) { --item-index: 2; }
.card:nth-child(4) { --item-index: 3; }
.card:nth-child(5) { --item-index: 4; }
.card:nth-child(6) { --item-index: 5; }

@keyframes scale-in {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

The Known-Item-Count Constraint

If the gallery has more than six cards, add more nth-child rules. That is the limitation of this technique: the number of items must be known at authoring time. For a gallery populated from a CMS or an API, writing the rules for 20 items is tedious but possible. For an unknown count, the Web Animations API (WAAPI) is the alternative, because it can read the DOM length and assign delays programmatically. The CSS specification has no native stagger() function, so this manual enumeration is the price of staying pure declarative.

A Loading Indicator Where Dots Animate in a Wave

The third sample is a loading indicator with three dots. Instead of each dot waiting its turn from the start, the wave effect uses negative animation-delay so that the second and third dots begin partway through the keyframe cycle. A negative delay of -0.3s on the second dot starts it at the 30% mark of a 1s motion, creating an offset wave without any initial waiting period.

.dot {
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: #333;
  animation: bounce 1s ease-in-out infinite;
}

.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: -0.33s; }
.dot:nth-child(3) { animation-delay: -0.66s; }

@keyframes bounce {
  0%, 100% {
    transform: translateY(0);
    opacity: 1;
  }
  50% {
    transform: translateY(-1rem);
    opacity: 0.5;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dot {
    animation: none;
    transform: none;
  }
}

The negative animation-delay is a common mistake when misunderstood: it does not start before time zero, it starts the motion at a point in the cycle determined by the delay value modulo the duration. Here, -0.33s means the second dot is one-third through the bounce when the page loads, so the wave appears already in motion. The iteration count is infinite, so the dots loop forever. Use this pattern for spinners, skeletons, or any repeated pulse.

Common Pitfalls and How the Cascade Bites

The single most frequent failure is forgetting animation-fill-mode: backwards. Without it, the element renders in its natural state during the delay, then jumps to the keyframe start. The result is a double flash: visible, then invisible, then animating. Always set animation-fill-mode: backwards or use the both shorthand. The second mistake is applying the custom property to a parent and expecting children to inherit distinct values; inheritance copies the same value, so every child gets the same delay. The third is using a delay that exceeds the animation-duration by a large factor, which makes the last items wait so long that users think the page is broken. Keep the total delay under about one second for a five-item list.

When the Default Experience Matters

The fallback for browsers that do not support animation-delay is a static layout. Check support data on caniuse.com before shipping. The @supports (animation-delay: 0s) guard can wrap the offset rules, leaving a simultaneous motion or no motion at all for older engines. This is the accepted fallback because a lack of motion is never a functional failure. The same principle applies to prefers-reduced-motion: the media query is not a progressive enhancement, it is a requirement. Stripping the motion and showing the final state, opacity: 1, transform: none, is the correct default for users who request reduced motion.

Why This Replaces the Old JavaScript Stagger Loops

Before this pattern was common, developers reached for setTimeout() or setInterval() loops that toggled classes on each element after a timed interval. A jQuery .delay() chained with .animate() on individual elements was the older technique. Both have the same weakness: they run on the main thread, they require JavaScript to be enabled, and they fight the browser’s compositor. A CSS motion with animation-delay lets the browser schedule the start and, because the animated properties are transform and opacity, hand the actual rendering to the compositor. The page stays responsive while the effect runs. The WAAPI alternative, element.animate(), also runs off-main-thread for these properties, but it is still JavaScript and still needs a script to enumerate the children. The CSS version is lighter and survives a script failure intact.

The Limitation: Dynamic Lists and the Absence of a Native Stagger

The technique breaks when the number of items is not known at authoring time. If your list is rendered from a database query, the nth-child rules only cover the items you wrote rules for; item 21 gets no custom property and falls back to the default --item-index: 0, so it animates immediately with the first item. The CSS specification has no native stagger() function, the Working Draft for CSS Animations Level 1 defines animation-delay as a simple time value, and the cascade has no way to know how many siblings an element has. The WAAPI is the practical alternative for dynamic lists, because it can read the DOM length and assign delays in a loop. This is not a defect in the technique; it is a boundary of the language. If you control the markup, the CSS pattern is superior. If the markup is generated at runtime, reach for the API.

Frequently Asked Questions

What is the correct animation-fill-mode for an offset entrance?

Use backwards or both. The backwards value applies the first keyframe during the delay period, so the element is hidden or positioned correctly before the motion starts. Without it, the element shows its natural state until the delay elapses, causing a flash.

Can I use negative animation-delay for a one-time offset?

Yes, but it changes the effect. A negative delay starts the motion partway through its cycle, so the first visible moment is not the first keyframe. For a one-time entrance, positive delays with fill-mode: backwards are clearer. Negative delays are best for infinite loops like a loading wave.

Does animation-delay work with the animation shorthand?

The shorthand animation: name duration timing-function delay iteration-count direction fill-mode accepts a delay as the second time value. The first time is duration, the second is delay. If you write animation: fade 0.6s ease-out 0.2s both, the 0.2s is the delay. Order matters.

Why do my offset items all start at once when I use a custom property?

Because the custom property is set on the parent and inherits the same value to every child. Set --item-index on each child directly, via nth-child or inline style. The cascade does not auto-increment a property for you.

Is there a way to offset without nth-child?

You can set the custom property inline in the markup, for example style="--item-index: 3". That works for any list, dynamic or not, but it moves the logic into the HTML. The nth-child approach keeps the styling in CSS, which is the point of a declarative technique.

What to Do Next

Open the list you are animating right now and replace the JavaScript stagger loop with the custom property pattern. Count the items, write the nth-child rules, set animation-fill-mode: backwards, and wrap the whole thing in a prefers-reduced-motion media query. That is the single most practical step: convert one existing motion to this declarative form and measure the difference in main-thread activity using the performance panel. You will see the compositor take over the transform and opacity work, and you will never reach for a setTimeout loop again.