Rejouice — loading0

Viewport stagger reveal

Grid or list items rise and fade in sequence as their container enters the viewport, with the stagger driven by index.

Grade: ARuntimes: css · gsapStatus: live
Viewport stagger reveal — one gesture, internal rhythm

Intent

Revealing a twelve-item grid all at once is a flash; revealing each item on its own observer is a mess of unrelated timings. Staggering from a single container observer makes the group read as one gesture with internal rhythm.

Implementation

`Math.min(i, 7)` caps the stagger so the last item in a long grid is not left waiting.

Dependencies: none

html-css-js
<ul class="stagger" data-stagger>
  <li class="stagger__item"></li>
  <li class="stagger__item"></li>
</ul>

<style>
.stagger__item {
  transition:
    opacity var(--dur-reveal, 0.95s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1))
      var(--item-delay, 0ms),
    transform var(--dur-reveal, 0.95s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1))
      var(--item-delay, 0ms);
}

.stagger.is-armed .stagger__item {
  opacity: 0;
  transform: translateY(1.5rem);
}

.stagger.is-armed.is-in .stagger__item {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .stagger.is-armed .stagger__item { opacity: 1; transform: none; transition: none; }
}
</style>

<script type="module">
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
  const io = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        entry.target.classList.add("is-in");
        io.unobserve(entry.target);
      }
    },
    { threshold: 0.15, rootMargin: "0px 0px -10% 0px" },
  );

  for (const list of document.querySelectorAll("[data-stagger]")) {
    list.querySelectorAll(".stagger__item").forEach((item, i) => {
      item.style.setProperty("--item-delay", Math.min(i, 7) * 45 + "ms");
    });
    list.classList.add("is-armed");
    io.observe(list);
  }
}
</script>

Use it when

  • Project grids, service lists, logo walls, stat rows.
  • Any repeated element where the group is the unit of meaning.

Avoid it when

  • Above the fold, where the delay costs perceived load time.
  • Lists longer than about twelve items — the last item's delay becomes a wait.

Accessibility

  • Reduced motion delivers opacity 1 and no transform, immediately.
  • Never make an item interactive before it is visible — a focusable card at opacity 0 is a keyboard trap in practice.

Performance

  • One observer per container keeps the callback count flat as the list grows.
  • Compose only `opacity` and `transform`.
  • Cap the effective index so a 40-item list does not end with a two-second delay.

Knobs

stagger
Delay per item. Default: 45ms
max stagger
Cap so long lists do not tail off. Default: 8 items
distance
Rise distance. Default: 1.5rem

Composition

Instruction for Claude Code

Add a staggered viewport reveal for list and grid items. Use one IntersectionObserver on the container (threshold 0.15, rootMargin bottom -10%), set a --item-delay custom property per item as min(index, 7) * 45ms, and transition opacity 0→1 and translateY(1.5rem)→0 over 640ms with ease-out-expo. JavaScript must add the armed class so the default CSS state is visible. Under prefers-reduced-motion, do not arm at all.