A Practical Introduction to Scroll-Driven Animations with CSS scroll() and view()

With just CSS you can add scroll animations that toggle shadows on navbars, reveal images, add scrollytelling, link up carousel elements and much more.

Let’s make a CSS scroll animation! No frameworks, no JavaScript. Connect user interaction with real time scroll interaction feedback; helping transition color, position, visibility, and more. 

At the time of writing this, it is Chromium only:

Browser Support:
  • ChromeSupported
  • FirefoxNot supported
  • Internet ExplorerNot supported
  • SafariNot supported
  • OperaNot supported

I feel scroll animations are perfect for progressive enhancement, but you may consider using a polyfill if it is crucial for them to function across all browsers today.

A humble spinning animation

Let’s begin with something familiar—an infinitely spinning element. It has keyframes that rotate the star five times over five seconds, continuously:

@keyframes spin {
  to {
    transform: rotateY(5turn);
  }
}

@media (prefers-reduced-motion: no-preference) {
  div {
    animation: spin 5s ease infinite;
  }
}

Try the demo

A scroll driven animation (SDA)

Let’s convert that animation to a scroll-driven animation, if the browser supports it. 

Add one line of CSS that instructs the animation to be triggered by scrolling, using the scroll() function:

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    div {
      animation: spin linear both;
      animation-timeline: scroll();
    } 
  }
}

Try the demo

What just happened?!

To the keyframes we defined, they still run from 0-100%. But now, 0% is the scroll start position and 100% is the scroll end position. You changed the animation-timeline.

Notice the addition of both, an animation-fill-mode value that allows the animation to play forwards and backwards.

I did add some supporting styles. I applied position: fixed to the image, allowing us to observe its spin as it remains in the viewport. Additionally, I modified the animation easing to linear because, in my experience, scroll-linked animations tend to feel better when linearly connected to my scrolling gesture.

A scroll port intersection driven animation

Next, change scroll() to view() and add one more line of CSS that specifies the animation-range:

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    div {
      animation: spin linear both;
      animation-timeline: view();
      animation-range: contain;
    }
  }
}

Try the demo

What just happened?!

To the keyframes we defined, they still run from 0-100% 🤓 But now, 0% is when the element is entering the scroll area and 100% is when it’s about to go out of that scroll area.

This view() function powers the animation as it crosses a scrollport.

The default value for animation-range is cover, which stinks in this star spinning demo because it makes it hard to distinguish between the scroll() demo; it would be spinning the whole time you see it, as it would spin edge to edge. So I’ve also added animation-range and set it to contain so that the animation is edge to edge within the scrollport. It’s a little goofy looking that it’s so stiff as it enters and exits, but I hope it’s at least super clear what’s happening.

Bramus has built a super rad tool that helps visualize the options you can pass into animation-range, definitely worth checking out if you intend to make some CSS scroll animations.

More elements!

We saw one element animate itself across the viewport, but what if multiple elements get assigned the same animation and view() attachment, but enter and exit the scrollport at different times? Let’s see!

In this next demo, each image scales up as it enters the scrollport, with individually running animations based on their unique scrollport intersection.

Instead of a keyword for the animation-range, this time I show that you can use lengths. In this case, I wanted cute product images to scale up once they were 25vh in and complete the scaling by 75vh of their scrolling area:

Try the demo

The CSS for this effect is just this:

@keyframes scale-a-lil {
  from {
    scale: .5;
  }
}
  
@media (prefers-reduced-motion: no-preference) {
  figure img {
    animation: scale-a-lil linear both;
    animation-timeline: view();
    animation-range: 25vh 75vh;
  }
}

The keyframes set the out state for when scaled down. The animation says scale the images to regular size given the element intersects with the scrollport at the provided animation-range.

More examples!

With those building blocks of scroll attachment, view attachment and animation ranges, there’s a lot you can do. Let’s have a look at some more examples.

Fade in the primary nav on scroll

This demo has a very clean and minimal initial load where navigation is hidden, but then a little bit of scroll brings it in. It also shows how to animated the navbar shadow on scroll:

Theme scroll

This next demo animates an angle @property CSS variable, which is then used in a cylindrical color space. As the user scrolls, it makes a rainbow effect because the effect changes 0turn to 1turn; encompassing all the hues in the color space:

Pull to refresh with scroll snap

In this demo, the pull-to-refresh icon rotates and the prompt text fades in as the user scrolls, effectively conveying the result of their gesture:

A more advanced example

Up until now, examples have been with the whole page, and vertical scroll only.

In this section I will show you how to:

  • Hookup horizontal scroll() animation
  • Link animation with scroll-snap points
  • Share scroll() or view() progress with other elements with timeline-scope

Try the demo

Setting up snapping horizontal scroll()

It’s time to pass some parameters to scroll(). It is a function after all 🤓

Attach the animation to horizontal scroll by specifying the axis in the function, such as scroll(x). Ensure that the axis matches the setup of your overflow. If you’re utilizing logical properties, you would use scroll(inline).

html {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  animation-timeline: scroll(x);

  body > section {
    scroll-snap-align: start;
  }
}

Now, any animation added to the markup will be controlled by the horizontal scroll of the page.

Animating carousel cards in and out

This is one of the best parts of the code to play with in the demo. It’s the animation as the cards go in and out of the horizontal scroll view:

The keyframes run from 0%-20% as elements “enter stage right”, then hold a natural state until 80% of the scroll area, then “exit stage left” finishing the 80%-100% part of the keyframes. My animation keyframes are like, “be shy as you enter the stage, then try to stick around as you exit the stage.” 😂

@keyframes fancy-in {
  /* card entry */
  0% {
    transform: translateX(25vw);
  }
  /* card on stage */
  20%, 80% {
    opacity: 1;
    transform: none;
  }
  /* card exit */
  100% {
    transform: translateX(90%) scale(.98);
    opacity: 0;
  }
}

You should definitely mess with this code. Change the timing percentages or change the transforms.

Next, connect these dynamic keyframes to the scroll intersection on the x-axis for each .card element. Ensure that the animation occurs only if motion is acceptable for the visitor.

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    animation: fancy-in linear both;
    animation-timeline: view(x);
  }
}

If you want to go off rails and play with what you’ve learned, try making the keyframes just 0%-100% and change the animation-range to get the effect. Or, add the animation ranges into the keyframes. There are options here, and room for you to find a preference as an author and crafter.

Crossfading the next and previous buttons with scroll()

It’s a nice, elegant touch to visually fade the next button when at the end of a carousel, and also fade out a previous button if at the start. We can use CSS to do this! Here are the keyframes, they make this look like it might be simple:

@keyframes toggle-control {
  50% { opacity: 0 }
}

The next section is advanced and extremely important when you want to redirect an intersection of one element to power a different element’s animation.

Define observable snap sections

Rather than attempting to fade out the buttons with an estimated animation range for snap sections’ size, each button can observe a specific section and its intersection with the scrollport. This allows the buttons to be perfectly animated as the first and last sections go in and out of view. It’s awesome.

Intrinsically sized snap area animation timelines

Use view-timeline to expose the view() progress of a specific element. Provide a custom name and axis. Soon we’ll make these names more public to other elements, they can reference a sections view progress to power their own animation.

#section-1 { view-timeline: --section-1 x }
#section-2 { view-timeline: --section-2 x }
#section-3 { view-timeline: --section-3 x }
#section-4 { view-timeline: --section-4 x }
#section-5 { view-timeline: --section-5 x }
#section-6 { view-timeline: --section-6 x }
#section-7 { view-timeline: --section-7 x }

Next, we have to talk a little bit about the HTML of the carousel. The buttons and the section elements are distant siblings in the DOM tree. In order for the buttons to be able to observe the timelines of distant relatives scrollport intersection, it’s essential to raise the scope of each section’s timeline into a shared parent element. This is done with the timeline-scope property.

Each section’s view-timeline-name is now available to all elements in <body>.

body {
  timeline-scope: 
    --section-1, 
    --section-2, 
    --section-3, 
    --section-4, 
    --section-5, 
    --section-6, 
    --section-7
  ;
}

I can now tell the previous (left arrow) button to fade out when the first section is showing, and tell the next (right arrow) button to fade out when the last section is showing.

.controls {
  & > button {
    /* if supported, enable the visibility toggling animation */
    @supports (animation-timeline: scroll()) {
      animation: toggle-control linear both;
    }

    /* fade out the previous button when section 1 is in view */
    &.previous {
      animation-timeline: --section-1;
    }

    /* fade out the next button when at the last section */
    &.next {
      animation-timeline: --section-7;
    }
  }
}

The work to expose each sections timeline pays off in the next section, where more buttons want to know which section is snapped.

Synchronizing the pagination dots with snapped carousel sections

First, we need some keyframes that represent being in and out of view. Since this is a view() linked animation, 0% is out of view to the left, 100% is out of view to the right, and 50% is when the specified --section by a pagination dot is snapped.

My keyframes chose scale and color to indicate a selected state.

@keyframes dot-selected { 
  0%, 100% {
    scale: .75;
  }
  50% {
    scale: 1;
    background: var(--text-2);
  }
}

My pagination consists of a series of links that target section IDs, which is advantageous because the browser handles scrolling elements into view, and they are focusable. The initial step is to apply our recently created animation to each dot.

.pagination > a {
  @supports (animation-timeline: scroll()) {
    animation: dot-selected linear both;  
  }
}

Link each dot’s animation progress to a relevant section’s view timeline.

.pagination > a {
  @supports (animation-timeline: scroll()) {
    animation: dot-selected linear both;  
  }

  &:nth-child(1) { animation-timeline: --section-1 }
  &:nth-child(2) { animation-timeline: --section-2 }
  &:nth-child(3) { animation-timeline: --section-3 }
  &:nth-child(4) { animation-timeline: --section-4 }
  &:nth-child(5) { animation-timeline: --section-5 }
  &:nth-child(6) { animation-timeline: --section-6 }
  &:nth-child(7) { animation-timeline: --section-7 }
}

Now pagination dots restore to normal size and are a brighter color when their linked section’s view() timeline is intersecting with the scroll area.

Animating the theme with scroll()

This part is extra credit and just super fun.

The entire scroll area of the carousel is linked to a full turn of an angle named --hue. As you scroll, the progress is converted into an angle between 0 and 1 turns. That angle is used as a hue variable inside an OKLCH color palette.

Scroll linked hue angles

The type safe casting of @property is what unlocks this.

Browser Support:
  • ChromeSupported
  • FirefoxNot supported
  • Internet ExplorerNot supported
  • SafariNot supported
  • OperaNot supported

Here’s how to define a type safe custom property in CSS:

@property --hue {
  syntax: '<angle>';
  initial-value: 0turn;
  inherits: false;
}

Use this typed property in some keyframes. The browser now knows to maintain the type when interpolating 0turn and 1turn, which also means it knows how to interpolate the value over time… or over scroll progress.

@keyframes hue-cycle { 
  to {
    --hue: 1turn;
  }
}

Link the progress of our carousel scroll to the keyframes with just a few lines.

:root {
  animation: hue-cycle linear both;
  animation-timeline: scroll(x);
}

Last, use the --hue variable inside a color palette. I chose oklch but you could also use hsl or any hue angle accepting color space. My little theme has a couple of text colors, a couple of surface colors, a link color and a focus color.

As the hue rotates on scroll, all of these color variables update.

:root {
  /* dynamic color props, hue powered by scroll */
  --surface-1: oklch(40% 50% var(--hue));
  --surface-2: oklch(50% 40% var(--hue));
  --text-1: oklch(98% 10% var(--hue));
  --text-2: oklch(95% 20% var(--hue));
  --link: oklch(99% 10% var(--hue));
  --focus: oklch(80% 90% var(--hue));

  /* fallback for if @property isnt supported */
  --hue: 275;
}

Add a neat gradient trick by leveraging background-attachment to make a nice vignette radial gradient that changes color but doesn’t move on scroll. I also use relative color syntax for a quick one off variant from the palette, it’s 25% lighter than surface 2, resulting in that nice sunburst effect.

:root {
  background: radial-gradient(closest-corner circle, 
    oklch(from var(--surface-2) calc(l * 1.25) c h),
    var(--surface-1)
  ) fixed no-repeat;
}

Field notes

Here are key takeaways of scroll() and view().

CSS scroll() recap

Features:

  • Links animation progress to any element with overflow auto, clip or scroll
  • Can use any scroll-timeline named element, not just a nearest parent scroller, as long as timeline-scope is used
  • Range can be customized to hone the animation timing

Great for:

CSS view() recap

Features:

Great for:

Not covered in this article

There’s syntax for combining keyframes and animation-range into one 🤯

@keyframes animate-in-and-out {
  entry 0%  {
    opacity: 0; transform: translateY(100%);
  }
  entry 100%  {
    opacity: 1; transform: translateY(0);
  }
  exit 0% {
    opacity: 1; transform: translateY(0);
  }
  exit 100% {
    opacity: 0; transform: translateY(-100%);
  }
}

#list-view li {
  animation: animate-in-and-out linear both;
  animation-timeline: view();
}

Read all about it here from Bramus, it’s slick.

Respecting User Motion

A good default user experience is a legible and static experience.

With the following media query, you are adding animations for users who have no indication via their device that they wish for reduced motion. I call this `motionOK` when using custom media queries.

@media (prefers-reduced-motion: no-preference) {
  /* OK to add scroll motion * /
}

Checking support

@supports (animation-timeline: scroll()) {
 
}

More reading

Tools

Stay in the loop: Get your dose of frontend twice a week

👾 Hey! Looking for the latest in frontend? Twice a week, we'll deliver the freshest frontend news, website inspo, cool code demos, videos and UI animations right to your inbox.

Zero fluff, all quality, to make your Mondays and Thursdays more creative!