22 CSS Transitions18 / 22

Pure CSSMIT licensed

Staggered Transition Delay with Custom Property Index

Per-item transition-delay computed from an index custom property set inline — style="--i: 3" plus calc(var(--i) * 60ms) — so a list of any length staggers from a single rule. Compared directly against the :nth-child() approach, which needs one selector per item and silently stops staggering the moment the list grows past the last rule you wrote.

Published Updated

Live Demo
Try it

The code

<section class="trn-18" aria-labelledby="trn-18-heading">
  <div class="trn-18__stage">
    <div class="trn-18__theme">
      <input class="trn-18__themecb" type="checkbox" id="trn-18-theme">
      <label class="trn-18__themebtn" for="trn-18-theme">
        <span class="trn-18__themeico" aria-hidden="true">
          <svg class="trn-18__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
          <svg class="trn-18__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
        </span>
        <span>Theme</span>
      </label>
    </div>
    <header class="trn-18__head">
      <h2 class="trn-18__heading" id="trn-18-heading">One rule, any number of items</h2>
      <p class="trn-18__sub">Hover or focus either column. Both stagger — only one of them keeps working when the list grows.</p>
    </header>

    <div class="trn-18__pair">
      <article class="trn-18__col">
        <h3 class="trn-18__label trn-18__label--good">calc(var(--i) * 60ms) — one rule, six items</h3>
        <div class="trn-18__list trn-18__list--var" tabindex="0">
          <p class="trn-18__row" style="--i: 0"><span>Deploy to production</span><code>--i: 0 · 0ms</code></p>
          <p class="trn-18__row" style="--i: 1"><span>Sync 4 repositories</span><code>--i: 1 · 60ms</code></p>
          <p class="trn-18__row" style="--i: 2"><span>Invite teammate</span><code>--i: 2 · 120ms</code></p>
          <p class="trn-18__row" style="--i: 3"><span>Rotate access keys</span><code>--i: 3 · 180ms</code></p>
          <p class="trn-18__row" style="--i: 4"><span>Review audit log</span><code>--i: 4 · 240ms</code></p>
          <p class="trn-18__row" style="--i: 5"><span>Archive workspace</span><code>--i: 5 · 300ms</code></p>
        </div>
      </article>

      <article class="trn-18__col">
        <h3 class="trn-18__label trn-18__label--bad">:nth-child() — five rules, six items</h3>
        <div class="trn-18__list trn-18__list--nth" tabindex="0">
          <p class="trn-18__row"><span>Deploy to production</span><code>rule 1 · 0ms</code></p>
          <p class="trn-18__row"><span>Sync 4 repositories</span><code>rule 2 · 60ms</code></p>
          <p class="trn-18__row"><span>Invite teammate</span><code>rule 3 · 120ms</code></p>
          <p class="trn-18__row"><span>Rotate access keys</span><code>rule 4 · 180ms</code></p>
          <p class="trn-18__row"><span>Review audit log</span><code>rule 5 · 240ms</code></p>
          <p class="trn-18__row"><span>Archive workspace</span><code>no rule · 0ms</code></p>
        </div>
      </article>
    </div>

    <p class="trn-18__foot">The sixth row on the right has no matching selector, so it arrives first and the cascade reads as broken. Nothing in the stylesheet says the list got longer.</p>
  </div>
</section>
.trn-18 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #f7f3ec;
  --page: oklch(0.96 0.012 82);
  --ink: #241f19;
  --ink: oklch(0.26 0.016 68);
  --muted: #6d6355;
  --muted: oklch(0.51 0.02 74);
  --rule: #d5ccbd;
  --rule: oklch(0.86 0.018 80);
  --coral: #d1532f;
  --coral: oklch(0.6 0.16 38);
  --bad: #8f6c1f;
  --bad: oklch(0.53 0.11 88);
  --step: 60ms;
  --font-display: "Bookman Old Style", "Rockwell", "Roboto Slab", Georgia, serif;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.trn-18 *,
.trn-18 *::before,
.trn-18 *::after {
  box-sizing: border-box;
}

.trn-18__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(22px, 5vw, 52px) 14px;
}

.trn-18__head {
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  margin: 0 0 20px;
}

.trn-18__heading {
  margin: 0 0 8px;
  font: 600 clamp(23px, 4.4vw, 34px)/1.1 var(--font-display);
  letter-spacing: -0.015em;
  text-wrap: balance;
}

.trn-18__sub {
  margin: 0;
  max-inline-size: 54ch;
  font-size: 14px;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.trn-18__pair {
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 18px;
}

.trn-18__col {
  min-inline-size: 0;
}

.trn-18__label {
  margin: 0 0 8px;
  font: 500 11.5px/1.4 ui-monospace, Menlo, monospace;
}

.trn-18__label--good {
  color: var(--coral);
}

.trn-18__label--bad {
  color: var(--bad);
}

.trn-18__list {
  border-block-start: 1px solid var(--ink);
}

.trn-18__list:focus-visible {
  outline: 2px solid var(--coral);
  outline-offset: 3px;
}

.trn-18__row {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 10px;
  align-items: baseline;
  justify-content: space-between;
  min-inline-size: 0;
  margin: 0;
  padding: 8px 2px;
  border-block-end: 1px solid var(--rule);
  opacity: 0.28;
  transform: translateY(-4px);
  transition: opacity 240ms ease, transform 240ms cubic-bezier(.2, .8, .3, 1);
}

.trn-18__row span {
  min-inline-size: 0;
  font: 500 13px/1.3 var(--font-display);
}

.trn-18__row code {
  font: 400 10.5px/1 ui-monospace, Menlo, monospace;
  color: var(--muted);
}

.trn-18__list--var .trn-18__row {
  transition-delay: calc(min(var(--i, 0), 5) * var(--step));
}

.trn-18__list--var:hover .trn-18__row,
.trn-18__list--var:focus-visible .trn-18__row {
  opacity: 1;
  transform: translateY(0);
}

.trn-18__list--nth .trn-18__row:nth-child(1) {
  transition-delay: 0ms;
}

.trn-18__list--nth .trn-18__row:nth-child(2) {
  transition-delay: 60ms;
}

.trn-18__list--nth .trn-18__row:nth-child(3) {
  transition-delay: 120ms;
}

.trn-18__list--nth .trn-18__row:nth-child(4) {
  transition-delay: 180ms;
}

.trn-18__list--nth .trn-18__row:nth-child(5) {
  transition-delay: 240ms;
}

.trn-18__list--nth:hover .trn-18__row,
.trn-18__list--nth:focus-visible .trn-18__row {
  opacity: 1;
  transform: translateY(0);
}

.trn-18__foot {
  inline-size: 100%;
  max-inline-size: 58rem;
  margin: 18px 0 0;
  padding-block-start: 14px;
  border-block-start: 1px solid var(--rule);
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--muted);
}

@media (prefers-reduced-motion: reduce) {
  .trn-18__row {
    transform: none;
    transition: opacity 140ms ease;
  }

  .trn-18__list--var .trn-18__row {
    transition-delay: calc(min(var(--i, 0), 5) * 15ms);
  }

  .trn-18__list--nth .trn-18__row:nth-child(1),
    .trn-18__list--nth .trn-18__row:nth-child(2),
    .trn-18__list--nth .trn-18__row:nth-child(3),
    .trn-18__list--nth .trn-18__row:nth-child(4),
    .trn-18__list--nth .trn-18__row:nth-child(5) {
    transition-delay: 15ms;
  }

  .trn-18__list--var:hover .trn-18__row,
    .trn-18__list--var:focus-visible .trn-18__row,
    .trn-18__list--nth:hover .trn-18__row,
    .trn-18__list--nth:focus-visible .trn-18__row {
    transform: none;
  }
}

@media (prefers-color-scheme: dark) {
  .trn-18 {
    --page: #1a1712;
    --page: oklch(0.21 0.014 70);
    --ink: #f2ece1;
    --ink: oklch(0.95 0.014 82);
    --muted: #a79c8c;
    --muted: oklch(0.71 0.02 80);
    --rule: #322c24;
    --rule: oklch(0.3 0.016 74);
    --coral: #f08256;
    --coral: oklch(0.75 0.14 42);
    --bad: #d3b25c;
    --bad: oklch(0.79 0.11 90);
  }
}

[data-theme="dark"] .trn-18 {
  --page: #1a1712;
  --ink: #f2ece1;
  --muted: #a79c8c;
  --rule: #322c24;
  --coral: #f08256;
  --bad: #d3b25c;
}

.trn-18__theme {
  position: relative;
  inline-size: 100%;
  min-inline-size: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0 0 14px;
}

.trn-18__themecb {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-18__themebtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
  font: 500 11px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: color 200ms ease, opacity 200ms ease;
}

.trn-18__themebtn:hover {
  color: var(--ink);
}

.trn-18__themecb:focus-visible + .trn-18__themebtn {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.trn-18__themeico {
  display: grid;
  place-items: center;
  inline-size: 15px;
  block-size: 15px;
}

.trn-18__themeico svg {
  grid-area: 1 / 1;
  transition: opacity 200ms ease;
}

.trn-18__sun {
  opacity: 1;
}

.trn-18__moon {
  opacity: 0.34;
}

.trn-18:has(#trn-18-theme:checked) .trn-18__sun {
  opacity: 0.34;
}

.trn-18:has(#trn-18-theme:checked) .trn-18__moon {
  opacity: 1;
}

@media (prefers-color-scheme: light) {
  .trn-18:has(#trn-18-theme:checked) {
    --page: #1a1712;
    --page: oklch(0.21 0.014 70);
    --ink: #f2ece1;
    --ink: oklch(0.95 0.014 82);
    --muted: #a79c8c;
    --muted: oklch(0.71 0.02 80);
    --rule: #322c24;
    --rule: oklch(0.3 0.016 74);
    --coral: #f08256;
    --coral: oklch(0.75 0.14 42);
    --bad: #d3b25c;
    --bad: oklch(0.79 0.11 90);
  }
}

@media (prefers-color-scheme: dark) {
  .trn-18:has(#trn-18-theme:checked) {
    --page: #f7f3ec;
    --page: oklch(0.96 0.012 82);
    --ink: #241f19;
    --ink: oklch(0.26 0.016 68);
    --muted: #6d6355;
    --muted: oklch(0.51 0.02 74);
    --rule: #d5ccbd;
    --rule: oklch(0.86 0.018 80);
    --coral: #d1532f;
    --coral: oklch(0.6 0.16 38);
    --bad: #8f6c1f;
    --bad: oklch(0.53 0.11 88);
    --step: 60ms;
    --font-display: "Bookman Old Style", "Rockwell", "Roboto Slab", Georgia, serif;
    background: var(--page);
  }

  .trn-18:has(#trn-18-theme:checked) .trn-18__sun {
    opacity: 1;
  }

  .trn-18:has(#trn-18-theme:checked) .trn-18__moon {
    opacity: 0.34;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Staggered Transition Delay with Custom Property Index
Source: https://codefronts.com/motion/css-transition-designs/staggered-list-animation/

Per-item transition-delay computed from an index custom property set inline — style="--i: 3" plus calc(var(--i) * 60ms) — so a list of any length staggers from a single rule. Compared directly against the :nth-child() approach, which needs one selector per item and silently stops staggering the moment the list grows past the last rule you wrote.
## HTML
```html
<section class="trn-18" aria-labelledby="trn-18-heading">
  <div class="trn-18__stage">
    <div class="trn-18__theme">
      <input class="trn-18__themecb" type="checkbox" id="trn-18-theme">
      <label class="trn-18__themebtn" for="trn-18-theme">
        <span class="trn-18__themeico" aria-hidden="true">
          <svg class="trn-18__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
          <svg class="trn-18__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
        </span>
        <span>Theme</span>
      </label>
    </div>
    <header class="trn-18__head">
      <h2 class="trn-18__heading" id="trn-18-heading">One rule, any number of items</h2>
      <p class="trn-18__sub">Hover or focus either column. Both stagger — only one of them keeps working when the list grows.</p>
    </header>

    <div class="trn-18__pair">
      <article class="trn-18__col">
        <h3 class="trn-18__label trn-18__label--good">calc(var(--i) * 60ms) — one rule, six items</h3>
        <div class="trn-18__list trn-18__list--var" tabindex="0">
          <p class="trn-18__row" style="--i: 0"><span>Deploy to production</span><code>--i: 0 · 0ms</code></p>
          <p class="trn-18__row" style="--i: 1"><span>Sync 4 repositories</span><code>--i: 1 · 60ms</code></p>
          <p class="trn-18__row" style="--i: 2"><span>Invite teammate</span><code>--i: 2 · 120ms</code></p>
          <p class="trn-18__row" style="--i: 3"><span>Rotate access keys</span><code>--i: 3 · 180ms</code></p>
          <p class="trn-18__row" style="--i: 4"><span>Review audit log</span><code>--i: 4 · 240ms</code></p>
          <p class="trn-18__row" style="--i: 5"><span>Archive workspace</span><code>--i: 5 · 300ms</code></p>
        </div>
      </article>

      <article class="trn-18__col">
        <h3 class="trn-18__label trn-18__label--bad">:nth-child() — five rules, six items</h3>
        <div class="trn-18__list trn-18__list--nth" tabindex="0">
          <p class="trn-18__row"><span>Deploy to production</span><code>rule 1 · 0ms</code></p>
          <p class="trn-18__row"><span>Sync 4 repositories</span><code>rule 2 · 60ms</code></p>
          <p class="trn-18__row"><span>Invite teammate</span><code>rule 3 · 120ms</code></p>
          <p class="trn-18__row"><span>Rotate access keys</span><code>rule 4 · 180ms</code></p>
          <p class="trn-18__row"><span>Review audit log</span><code>rule 5 · 240ms</code></p>
          <p class="trn-18__row"><span>Archive workspace</span><code>no rule · 0ms</code></p>
        </div>
      </article>
    </div>

    <p class="trn-18__foot">The sixth row on the right has no matching selector, so it arrives first and the cascade reads as broken. Nothing in the stylesheet says the list got longer.</p>
  </div>
</section>
```
## CSS
```css
.trn-18 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #f7f3ec;
  --page: oklch(0.96 0.012 82);
  --ink: #241f19;
  --ink: oklch(0.26 0.016 68);
  --muted: #6d6355;
  --muted: oklch(0.51 0.02 74);
  --rule: #d5ccbd;
  --rule: oklch(0.86 0.018 80);
  --coral: #d1532f;
  --coral: oklch(0.6 0.16 38);
  --bad: #8f6c1f;
  --bad: oklch(0.53 0.11 88);
  --step: 60ms;
  --font-display: "Bookman Old Style", "Rockwell", "Roboto Slab", Georgia, serif;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.trn-18 *,
.trn-18 *::before,
.trn-18 *::after {
  box-sizing: border-box;
}

.trn-18__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(22px, 5vw, 52px) 14px;
}

.trn-18__head {
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  margin: 0 0 20px;
}

.trn-18__heading {
  margin: 0 0 8px;
  font: 600 clamp(23px, 4.4vw, 34px)/1.1 var(--font-display);
  letter-spacing: -0.015em;
  text-wrap: balance;
}

.trn-18__sub {
  margin: 0;
  max-inline-size: 54ch;
  font-size: 14px;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.trn-18__pair {
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 18px;
}

.trn-18__col {
  min-inline-size: 0;
}

.trn-18__label {
  margin: 0 0 8px;
  font: 500 11.5px/1.4 ui-monospace, Menlo, monospace;
}

.trn-18__label--good {
  color: var(--coral);
}

.trn-18__label--bad {
  color: var(--bad);
}

.trn-18__list {
  border-block-start: 1px solid var(--ink);
}

.trn-18__list:focus-visible {
  outline: 2px solid var(--coral);
  outline-offset: 3px;
}

.trn-18__row {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 10px;
  align-items: baseline;
  justify-content: space-between;
  min-inline-size: 0;
  margin: 0;
  padding: 8px 2px;
  border-block-end: 1px solid var(--rule);
  opacity: 0.28;
  transform: translateY(-4px);
  transition: opacity 240ms ease, transform 240ms cubic-bezier(.2, .8, .3, 1);
}

.trn-18__row span {
  min-inline-size: 0;
  font: 500 13px/1.3 var(--font-display);
}

.trn-18__row code {
  font: 400 10.5px/1 ui-monospace, Menlo, monospace;
  color: var(--muted);
}

.trn-18__list--var .trn-18__row {
  transition-delay: calc(min(var(--i, 0), 5) * var(--step));
}

.trn-18__list--var:hover .trn-18__row,
.trn-18__list--var:focus-visible .trn-18__row {
  opacity: 1;
  transform: translateY(0);
}

.trn-18__list--nth .trn-18__row:nth-child(1) {
  transition-delay: 0ms;
}

.trn-18__list--nth .trn-18__row:nth-child(2) {
  transition-delay: 60ms;
}

.trn-18__list--nth .trn-18__row:nth-child(3) {
  transition-delay: 120ms;
}

.trn-18__list--nth .trn-18__row:nth-child(4) {
  transition-delay: 180ms;
}

.trn-18__list--nth .trn-18__row:nth-child(5) {
  transition-delay: 240ms;
}

.trn-18__list--nth:hover .trn-18__row,
.trn-18__list--nth:focus-visible .trn-18__row {
  opacity: 1;
  transform: translateY(0);
}

.trn-18__foot {
  inline-size: 100%;
  max-inline-size: 58rem;
  margin: 18px 0 0;
  padding-block-start: 14px;
  border-block-start: 1px solid var(--rule);
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--muted);
}

@media (prefers-reduced-motion: reduce) {
  .trn-18__row {
    transform: none;
    transition: opacity 140ms ease;
  }

  .trn-18__list--var .trn-18__row {
    transition-delay: calc(min(var(--i, 0), 5) * 15ms);
  }

  .trn-18__list--nth .trn-18__row:nth-child(1),
    .trn-18__list--nth .trn-18__row:nth-child(2),
    .trn-18__list--nth .trn-18__row:nth-child(3),
    .trn-18__list--nth .trn-18__row:nth-child(4),
    .trn-18__list--nth .trn-18__row:nth-child(5) {
    transition-delay: 15ms;
  }

  .trn-18__list--var:hover .trn-18__row,
    .trn-18__list--var:focus-visible .trn-18__row,
    .trn-18__list--nth:hover .trn-18__row,
    .trn-18__list--nth:focus-visible .trn-18__row {
    transform: none;
  }
}

@media (prefers-color-scheme: dark) {
  .trn-18 {
    --page: #1a1712;
    --page: oklch(0.21 0.014 70);
    --ink: #f2ece1;
    --ink: oklch(0.95 0.014 82);
    --muted: #a79c8c;
    --muted: oklch(0.71 0.02 80);
    --rule: #322c24;
    --rule: oklch(0.3 0.016 74);
    --coral: #f08256;
    --coral: oklch(0.75 0.14 42);
    --bad: #d3b25c;
    --bad: oklch(0.79 0.11 90);
  }
}

[data-theme="dark"] .trn-18 {
  --page: #1a1712;
  --ink: #f2ece1;
  --muted: #a79c8c;
  --rule: #322c24;
  --coral: #f08256;
  --bad: #d3b25c;
}

.trn-18__theme {
  position: relative;
  inline-size: 100%;
  min-inline-size: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0 0 14px;
}

.trn-18__themecb {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-18__themebtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
  font: 500 11px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: color 200ms ease, opacity 200ms ease;
}

.trn-18__themebtn:hover {
  color: var(--ink);
}

.trn-18__themecb:focus-visible + .trn-18__themebtn {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.trn-18__themeico {
  display: grid;
  place-items: center;
  inline-size: 15px;
  block-size: 15px;
}

.trn-18__themeico svg {
  grid-area: 1 / 1;
  transition: opacity 200ms ease;
}

.trn-18__sun {
  opacity: 1;
}

.trn-18__moon {
  opacity: 0.34;
}

.trn-18:has(#trn-18-theme:checked) .trn-18__sun {
  opacity: 0.34;
}

.trn-18:has(#trn-18-theme:checked) .trn-18__moon {
  opacity: 1;
}

@media (prefers-color-scheme: light) {
  .trn-18:has(#trn-18-theme:checked) {
    --page: #1a1712;
    --page: oklch(0.21 0.014 70);
    --ink: #f2ece1;
    --ink: oklch(0.95 0.014 82);
    --muted: #a79c8c;
    --muted: oklch(0.71 0.02 80);
    --rule: #322c24;
    --rule: oklch(0.3 0.016 74);
    --coral: #f08256;
    --coral: oklch(0.75 0.14 42);
    --bad: #d3b25c;
    --bad: oklch(0.79 0.11 90);
  }
}

@media (prefers-color-scheme: dark) {
  .trn-18:has(#trn-18-theme:checked) {
    --page: #f7f3ec;
    --page: oklch(0.96 0.012 82);
    --ink: #241f19;
    --ink: oklch(0.26 0.016 68);
    --muted: #6d6355;
    --muted: oklch(0.51 0.02 74);
    --rule: #d5ccbd;
    --rule: oklch(0.86 0.018 80);
    --coral: #d1532f;
    --coral: oklch(0.6 0.16 38);
    --bad: #8f6c1f;
    --bad: oklch(0.53 0.11 88);
    --step: 60ms;
    --font-display: "Bookman Old Style", "Rockwell", "Roboto Slab", Georgia, serif;
    background: var(--page);
  }

  .trn-18:has(#trn-18-theme:checked) .trn-18__sun {
    opacity: 1;
  }

  .trn-18:has(#trn-18-theme:checked) .trn-18__moon {
    opacity: 0.34;
  }
}
```

How this works

The index is data, so put it in the markup. style="--i: 3" is the item's position expressed as a value CSS can compute with, and transition-delay: calc(var(--i) * 60ms) turns it into timing. One declaration serves a list of five or five hundred, and adding an item needs no CSS change at all.

:nth-child() does the same job at a cost that grows with the list. Six children means six selectors, each hard-coding a delay. Add a seventh and it enters with no delay, which reads as a broken stagger rather than a missing rule — and nothing in the CSS tells you the list got longer. The right-hand column here shows exactly that: five rules, six items.

Clamp the ladder or it becomes latency. Delay accumulates linearly, so a twenty-item list at 60ms puts the last item over a second behind the trigger. calc(min(var(--i), 5) * 60ms) keeps the first six items staggered and lands the rest together — the eye follows the leading edge, so the effect survives the clamp while the wait does not.

Fall back gracefully when the index is missing. Server-rendered lists occasionally lose the inline style; calc(var(--i, 0) * 60ms) means a missing index enters immediately rather than producing an invalid delay that drops the whole declaration. It is one character of defence for a class of bug that only appears in production.

Make it yours

  • Change the 60ms step to retune the whole ladder; 40ms feels brisk, 90ms feels ceremonial.
  • Wrap the index in min() to cap accumulated delay on long lists.
  • Multiply the index into a translateY as well for a fan-out rather than a straight cascade.
  • Reverse the ladder with calc((var(--n) - var(--i)) * 60ms) — the same technique demo 02 uses for exits.
  • Set the index from a data attribute instead if inline styles are blocked by your content policy.
  • Give the last item a longer duration so the cascade lands rather than stops.

Gotchas — read before shipping

  • A missing --i makes calc() invalid and drops the entire transition-delay declaration — always supply a fallback, as in var(--i, 0).
  • Delay accumulates: twenty items at 60ms is a 1.14s wait for the last one, which users read as the interface hanging.
  • :nth-child() stagger silently stops applying past the last rule you wrote, so the bug appears only when the list grows.
  • Custom properties inherit, so setting --i on a wrapper rather than the item gives every child the same delay and no stagger at all.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

calc() inside transition-delay with a custom property is baseline since 2017; the floor here is the oklch() palette (Chrome 111, Safari 15.4, Firefox 113), with sRGB hex declared first so the cascade looks identical in older engines. The in-demo theme toggle is pure CSS and relies on :has(), so an engine without it keeps the operating-system theme instead of flipping.

Techniques used in this demo

Search CodeFronts

Loading…