Triggering Animations on Scroll

animation-timeline is not supported in this browser. 😢

Here’s the idea: I don’t want to use animation-timeline to control animations based on scroll progress. I don’t want an animation that plays slower if you scroll slow or speeds up if you scroll fast. Instead, I want the animation to trigger once an element crosses a certain threshold. Then let it play out on its own. These 4 squares will act as our elements I want 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.

First I define the trigger. This need to be a parent component as the the trigger changes a custom prop that the child elements use for their transition. I use animation-name to define the animation that should play. I use animation-fill-mode to keep the animation in its final state. This needs to be both so it will keep its starting and ending keyframes. I use animation-timeline to define set that the animation should play based on the view of the section. I use animation-range to define the range the animation should play in. It’s defined to start when 100px are on screen and 150px are on screen. You can use percentages here also but you’ll run into the issue where it takes longer to trigger as your .trigger grows in length.

.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>

I then define some keyframes. I use --value to define the progress of the animation. I use from and to to define the start and end of the animation. I use --value to define the progress of the animation. I use from and to to define the start and end of the animation.

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

  to {
    --value: 1;
  }
}

I then define the transitions for the boxes. I then use the --value as a psuedo boolean value that goes from 0 to 1. I use the value in transform to to translate on the Y axis between 100px and 0px. The opacity is simply the --value due to it being 0 or 1. and the transition delay is based on the --index which is a custom prop that is passed to the child elements. This is so the boxes animate in one after the other.

.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