Rotating gallery with CSS scroll-driven animations

Jhey Tompkins

PublishedTime to read~5 min

Saw this neat gallery effect concept over on Twitter. It's built with Framer. It got me thinking about how exciting the CSS scroll-driven animations API is going to be. I couldn't resist building a version taking the API for a spin. And now I'm going to show you how to do it!

If you're not familiar with this API, I introduced it in "Building Chrometober". It allows you to drive CSS animations with scroll. The magic part is that this will all happen off the main thread. That means performant scroll-driven animations where JavaScript is optional.

It's currently experimental in Chrome. But, there is a polyfill that you can use. That's what powers today's demo.


The API has been through many changes. The compatibility data from MDN is a little inaccurate currently (That's what powers the <BrowserSupport> widget above). I'd recommend keeping an eye on the spec or the polyfill repo if you're interested in keeping up to date with things. Or keep in touch with me!

Drive type

At a high level, scroll-driven animations fall into two categories:

  1. Those driven by scroll position using scroll-timeline.
  2. Those driven by an element's position within its scroll container (scrollport) using view-timeline.

Today, we're concerned with the first of those. Driving animations based on scroll position.

Building this demo

First things first, we need that polyfill. Import this in a way that works for you. Here it is in a script tag.

<script src=""></script>

Up next, you need some elements to animate. A list with some images will work for what we want. For this demo, I've used for placeholder images:

    <li style="--x1: 2; --x2: 6; --y1: 1; --y2: 4;">
      <img src="" alt="">
    <li style="--x1: 6; --x2: 8; --y1: 2; --y2: 4;">
      <img src="" alt="">
    <!-- Obfuscated items -->

Now to lay them out in this grid-like manner. You have options here. You could use grid-template-areas and draw the layout in your CSS.

ul {
  grid-template-areas: ". a a a a . . . . ."
                       ". a a a a c c . ."
                       ". a a a a c c . .";
li:nth-of-type(1) {
  grid-area: a;

I'm not a huge fan of grid-template-areas though. They sometimes don't play nice.

You may have noticed the inline styles in the markup above:

<li style="--x1: 2; --x2: 6; --y1: 1; --y2: 4;">
  <img src="" alt="">

They define the coordinates for grid position. It might seem verbose to write these out. But, I like to lean into the use of custom property scope here. You can likely get away with positioning the main items and then rely on grid-auto-flow: dense too. Another approach would be having these coordinates in a JavaScript object. Then you can use whatever templating language you like to generate the markup and inline styles.

Anyway, back to the styles. Taking the custom property approach, you can scope the styles for the items:

ul {
  --big-tile-size: 50vmin;
  --rotation: 270deg;
  --scale: 0.4;
  --tile-size: calc(var(--big-tile-size) / 3);
  display: grid;
  grid-template: repeat(9, var(--tile-size)) / repeat(9, var(--tile-size));
  gap: 1vmin;
    translate(-50%, -50%)
li {
  grid-column: var(--x1, auto) / var(--x2, auto);
  grid-row: var(--y1, auto) / var(--y2, auto);

Now the grid is starting to take shape. There are custom properties to define the behavior of the grid and it's time for animation. This is where the --scale and --rotation properties are going to come into play.

You need to animate two things:

  1. The layout's scale and rotation.
  2. The image rotation to counter the layout rotation.

But, you don't need two keyframes for that. You can share one set using custom properties.

@keyframes scale-up {
  0% {
      translate(-50%, -50%)
  100% {
      translate(-50%, -50%)

The images set their direction to counter the layout rotation and use the same keyframes:

img {
  --rotation: -270deg;
  --scale: 1;

Now you can apply the animation. Let's set the iteration count to infinite to see it on loop.

ul, img {
  animation: scale-up 10s infinite ease-in-out alternate;

Ready for the last piece of the puzzle? Let's drive it with the body's scroll position.

ul, img {
  animation-timeline: scroll(root);
  animation: 1s scale-up both ease-in;

Yep. That's all you need for a scroll timeline. Remove the iteration count from our animation and set the animation-fill-mode to both. The animation-timeline property is set to scroll(root). This defines that the animation will get driven by the scroll of the root scroller. In this case, that's the body. To tie it all together, we fix the position of our layout and set the body height to the amount we'd like to scroll.

body { height: 300vh; }

And we get the end result!

Driving CSS animation with scroll and no JavaScript is an exciting prospect. I'm excited for it to make its way into browsers. Let's hope future versions of the spec include the ability to trigger or ease the animations.

Bonus (GSAP Solution)

The scroll-driven animations API is in its early stages. Someone did reach out asking if you could ease the scroll. If it were me, I'd be reaching for GreenSock. The ScrollTrigger plugin handles this use case well with the scrub property.

You have to adjust your implementation a little. But, you can power the whole thing with this block of JavaScript:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger"


  duration: 2,
  ease: "power1.inOut"

const rotate = 270

    scrollTrigger: {
      scrub: 1, // smooth scrubbing catch up duration
  .to("ul", {
    scale: 1,
      rotate: -rotate

That's it! The CSS scroll-driven animation API is an exciting one. I've been playing with it a bunch seeing what kind of things you can do with it. I'm excited to see it in your hands! Until that API matures though, solutions like GreenSock's ScrollTrigger are fantastic.

Stay awesome!

Further reading