Boise ID
00 00
Sun feels good today.
Modern image parallax using CSS scroll-timeline

What's old is new again: those who have been at this for a while remember the original explosion of the parallax effect in web marketing circa 2012 (I dug up an early example for those who don't remember.)

The web's late obsession with scroll-driven animations has brought back the parallax effect with a more modern and subtle twistβ€”and this time, we're not using jQuery.

Modern image parallax effect

Here's an example of the effect and what we're going to talk about in this post:

Finished example

Against the motion of the page and the image's frame, this subtle movement on scroll adds a very organic and almost physical layer of depth that makes it very unlike the popular parallax of 10 years ago.

Here's an approach using the new scroll-timeline feature that avoids unnecessary scroll event listeners and keeps visually smooth without a synthetic scrolling replacement.

Brief introduction to scroll-timeline

This post is going to assume you're already familiar with CSS @keyframes animations. Here's a quick primer to get you caught up if you aren't already.

The scroll-timeline API introduces two new features for linking an animation's progress to the scroll position:

  • scroll(), which links the animation to the total progress of a scroll container
  • and view(), which links the animation to the position of an element relative to its scroll container

For this effect, we're going to be using view().

Here's how a typical CSS @keyframes animation might be set up:

parallax-images.css
.parallax-image { width: 600px; height: 800px; max-height: 90lvh; overflow: clip; img { width: 100%; height: 100%; object-fit: cover; animation: parallax linear 5s; } } @keyframes parallax { 0% { transform: translateY(-25%); } 100% { transform: translateY(25%); } }

Notice: we're using overflow: clip;!

CSS authors may conventionally reach for overflow: hidden; to ensure the image is contained within the frame. Don't! overflow: clip; functions the same visually without establishing a new formatting context. This is essential for the effect to work.

This straightforward animation is actually already getting us pretty close to our desired effect, but there are a couple things missing:

  • It isn't attached to our scroll progress
  • As the image translates downward, it no longer covers our entire frame

Enter scroll-timeline and the view() function

Expressly stated, the effect we're trying to achieve is this: as the user scrolls down the page, the image is subtly translated to follow the user's scrolling to give an apparent depth to the image and frame.

To that end, we need to animate the image between the range where it enters the bottom of the viewport and exits the top of the viewport.

In theory, this could be achieved by watching the page's overall scroll progress using the scroll() function, but the specification authors graced us with view(), which handles this specific use case: attaching an animation to the position of an element within its scrolling containerβ€”in this case, the user's viewport.

Here's how we'll set that up (I've broken apart the animation shorthand property for clarity):

parallax-images.css
.parallax-image { width: 600px; height: 800px; max-height: 90lvh; overflow: clip; img { width: 100%; height: 100%; object-fit: cover; animation-name: parallax; animation-timeline: view(); } } @keyframes parallax { 0% { transform: translateY(-25%); } 100% { transform: translateY(25%); } }

This is a great start, but we still haven't quite achieved the effect that we want.

The image is the same size as the frame, so when it translates up and down, it isn't being cropped in the desired way.

Scaling the image correctly

In our prior examples, we're simply translating the image by 25% (of the image's own height) up or down based on its relative position within the viewport. However, to make sure it's covering the entire frame in spite of this movement, we're going to need to scale it up.

The simple formula: our image height (100%) plus the top offset (25%) plus the bottom offset (25%) equals our total needed image size (150%).

β‰ˆRemember: the order in which we apply CSS transformations matters! In this case, we'll simply scale it after the translation is applied.

Here's our updated animation:

parallax-images.css
@keyframes parallax { 0% { transform: translateY(-25%) scale(150%); } 100% { transform: translateY(25%) scale(150%); } }

This is looking great! We've built what we set out to. But what if we took things a step further?

Parameterizing the effect

Our solution could be more elegant if we parameterized its configuration.

Which is to say, instead of manually specifying the distance twice in each animation, and instead of calculating the scale by hand, what if we handled all of these automatically?

Thanks to the new capabilities of the attr() function, we can do this very elegantly.

Here's my proposed API:

<div class="parallax-image" data-distance="25%"><img src={"..."} /></div>

This is very easy to write & very easy to integrate within a component system. Let's make it happen!

First, let's get the distance value from the data attribute:

--parallax-distance: attr(data-distance type(<percentage>), 10%);

The arguments we're passing to the attr() function are as follows:

  • The attribute name (in our case, data-distance)
  • The data typeβ€”since all data-attributes are inherently strings, we should cast it to a percentage since that's how we intend to use it. This could be extended in the future to support any length measurement, i.e. pixels or rems as well!
  • The default value (I chose 10%!)

Next, in order to support negative distances, let's get the absolute value of the distance:

--abs-parallax-distance: max(var(--parallax-distance), calc(var(--parallax-distance) * -1));

(You're definitely familiar with this one. Just take the greater between the value, and the value multiplied by negative one)

Next, we'll calculate the image scale using the same formula from earlier:

--parallax-image-scale: calc(100% + (var(--abs-parallax-distance) * 2));

And, finally, we'll update our animation to support our new parameters:

parallax-images.css
.parallax-image { width: 600px; height: 800px; max-height: 90lvh; overflow: clip; --parallax-distance: attr(data-distance type(<percentage>), 10%); --abs-parallax-distance: max(var(--parallax-distance), calc(var(--parallax-distance) * -1)); --parallax-image-scale: calc(100% + (var(--abs-parallax-distance) * 2)); img { width: 100%; height: 100%; object-fit: cover; animation-name: parallax; animation-timeline: view(); } } @keyframes parallax { 0% { transform: translateY(var(--parallax-distance)) scale(var(--parallax-image-scale)); } 100% { transform: translateY(calc(var(--parallax-distance) * -1)) scale(var(--parallax-image-scale)); } }

Way cool! But...

What about horizontal scrolling?

Let's finish this component off by adding support for horizontal scrolling.

Our new API:

<div class="parallax-image" data-distance="25%" data-direction="x"> <img src="{...}" /> </div>

And our final stylesheet:

parallax-images.css
html, body { width: 100%; height: 100%; } body { background: #efefef; display: flex; flex-direction: column; align-items: center; justify-content: center; padding-left: 70vw; padding-right: 70vw; } .parallax-image { width: 600px; height: 800px; max-height: 90lvh; max-width: 90vw; overflow: clip; --parallax-distance: attr(data-distance type(<percentage>), 10%); --abs-parallax-distance: max(var(--parallax-distance), calc(var(--parallax-distance) * -1)); --parallax-image-scale: calc(100% + (var(--abs-parallax-distance) * 2)); img { width: 100%; height: 100%; object-fit: cover; animation-name: parallax; } &[data-direction="x"] img { animation-name: horizontal-parallax; animation-timeline: view(x); } /* Default to vertical parallax if not direction="x" */ &:not([data-direction="x"]) img, &[data-parallax-direction="y"] img { animation-name: vertical-parallax; animation-timeline: view(y); } } @keyframes vertical-parallax { 0% { transform: translateY(var(--parallax-distance)) scale(var(--parallax-image-scale)); } 100% { transform: translateY(calc(var(--parallax-distance) * -1)) scale(var(--parallax-image-scale)); } } @keyframes horizontal-parallax { 0% { transform: translateX(calc(var(--parallax-distance) * -1)) scale(var(--parallax-image-scale)); } 100% { transform: translateX(var(--parallax-distance)) scale(var(--parallax-image-scale)); } }

There you have it! Beautiful, modern, simple, performant, with an elegant API. I think we can ship this.

There are still some improvements which could be made by the ambitious:

  • Calculate X and Y based on the data-direction parameter and have one parallax animation which supports both directions
  • Wrap the image in another container that is animated separately to support both horizontal and vertical scrolling in the same elementβ€”just watch out for doubling up on the scale() factor and scoping your view() function correctly!

This post was written entirely by a human without the assistance of artificial intelligence (or spellcheck 😎).

In this article
MacCarrithers