Triggering Animations on Scroll

This feature is not supported in your browser.

My goal here is to create scroll triggered animations that pop in once you cross the threshold. Not animations that use the scroll position as a timeline creating half animated elements.

I want an approach similar to what I would get with an intersection observer. Once it is determined to be enough on screen, it pops 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 keeping both starting and ending keyframes. The animation-timeline: view() bases the animation on the section’s visibility, and animation-range controls when it plays. The animation starts when 100px are on screen and ending at 150px. You can use percentages, but longer .trigger elements take more time to activate. So this is why we are using pixel values here.

.trigger {
  animation-name: enter;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry 100px 150px;
}
<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 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;
  /* Linear easing used here to have greater
  control of the easing curve */
  --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

Browser Support

ChromeFirefoxSafariEdge
animation-timeline✅ 115+✅ preview+✅ 26+✅ 115+
animation-range✅ 115+✅ preview+✅ 26+✅ 115+