Triggering Animations on Scroll

animation-timeline is not supported in this browser. 😢

The goal: trigger animations once when elements cross a threshold, not control them based on scroll speed. These 4 squares will act as our elements to animate in.

I want an approach similar to what I would get with an interaction observer. Once it is determined to be enough on screen, it flips a class and plays the animation. I want to do this with just CSS. No JS needed.

The trigger must be a parent component since it changes a custom property that child elements use for their transition. The animation-name defines which animation plays, while animation-fill-mode: both keeps the animation in its final state by preserving both starting and ending keyframes. The animation-timeline: view() bases the animation on the section’s visibility, and animation-range controls when it plays—starting when 100px are on screen and ending at 150px. You can use percentages, but longer .trigger elements take more time to activate.

.trigger {
  animation-name: enter;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry 100px 150px;
}

Here is the markup

<div class="trigger-wrapper">
  <div class="layout">
    <div class="box" style="--index: 0"></div>
    <div class="box" style="--index: 1"></div>
    <div class="box" style="--index: 2"></div>
    <div class="box" style="--index: 3"></div>
  </div>
</div>

The keyframes use a --value custom property to track animation progress from 0 to 1.

@keyframes enter {
  from {
    --value: 0;
  }

  to {
    --value: 1;
  }
}

The box transitions use --value as a pseudo-boolean that interpolates from 0 to 1. This value controls the Y-axis translation between 100px and 0px, while opacity directly uses --value since it ranges from 0 to 1. Each box’s transition delay is based on its --index custom property, creating a staggered animation effect.

.box {
  transform: translateY(calc(100px - var(--value) * 100px));
  transition: transform 1s ease-in-out, opacity 1s ease-in-out;
  transition-delay: calc(200ms * var(--index));
  opacity: var(--value);
}

Here is it all put together.

HTML

<div class="trigger">
  <div class="layout">
    <div class="box" style="--index: 0"></div>
    <div class="box" style="--index: 1"></div>
    <div class="box" style="--index: 2"></div>
    <div class="box" style="--index: 3"></div>
  </div>
</div>

CSS

:root {
  --gap: 30px;
  --duration: 1s;
  --delay: 200ms;
  /* https://easingwizard.com/ */
  --glide: linear(
    0,
    0.012 0.9%,
    0.049 2%,
    0.409 9.3%,
    0.513 11.9%,
    0.606 14.7%,
    0.691 17.9%,
    0.762 21.3%,
    0.82 25%,
    0.868 29.1%,
    0.907 33.6%,
    0.937 38.7%,
    0.976 51.3%,
    0.994 68.8%,
    1
  );
}

.trigger {
  margin-block: 3rem;
  animation-name: enter;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry 100px 150px;
}

.layout {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
  gap: var(--gap);
}

.box {
  display: inline-flex;
  width: 100%;
  max-width: 500px;
  aspect-ratio: 1;
  background: #b3c33a;
  transform: translateY(calc(100px - var(--value) * 100px));
  transition: transform var(--duration) var(--glide), opacity var(--duration) var(
        --glide
      );
  transition-delay: calc(var(--delay) * var(--index));
  opacity: var(--value);
}

@keyframes enter {
  from {
    --value: 0;
  }

  to {
    --value: 1;
  }
}

JS

// 404