How to Add Smooth Scrolling with Inner Image Animations to a Web Page

How to Add Smooth Scrolling with Inner Image Animations to a Web Page

Today we want to show you how to add smooth scrolling in HTML with some additional subtle animations on images. With “smooth scrolling” we don’t mean smoothly scrolling to an element, but rather a smoothly animated kind of scrolling behavior. There are many beautiful examples of such smooth scrolling behavior on some recent websites, like Elena Iv-skaya, or Ada Sokół and the stunning site of Rafal Bojar, and many many others. The latter also has a very nice image animation that is synced with the scrolling. This kind of “inner” image animation adds another interesting layer to the whole scroll movement.

Why is this kind of smooth scrolling something you’d like to add to a web page? If you have ever animated something on scroll, you might have experienced that browsers have difficulties in displaying the incoming content jank-free; especially images may show tiny abrupt jumps on scroll. It just feel easy on the eye. To avoid that, we can use the trick of animating the content itself by translating it up or down instead of using the “native” scroll.

Smooth Scrolling

Jesper Landberg created some really great Codepen demos showcasing how smooth scrolling can be applied to different scenarios. The Smooth scroll with skew effect demo shows how to add a skew effect to images while (smooth) scrolling. Here you can also see how smooth scrolling with translating the content works: a content wrapper is set to position fixed with the overflow set to hidden so that its child can be moved. The body will get the height of the content set to it, so that we preserve the scroll bar. When we scroll, the fixed wrapper will stay in place while we animate the inner content. This trick makes a simple yet effective smooth scrolling behavior possible.

In our example we’ll use the following structure:


	

The main element will serve as fixed, or “sticky”, container while the [data-scroll] div will get translated.

Inner Image Animation

For the inner image animation we need an image and a parent container that has its overflow set to “hidden”. The idea is to move the image up or down while we scroll. We will work with a background image on a div so that we can control the overflow size better. Mainly, we need to make sure that the image div is bigger than its parent. This is our markup:

Let’s set the styles for these elements. We will use a padding instead of a height so that we can set the right aspect ratio for the inner div which will have the image as background. For this, we use an aspect ratio variable so that we simply need to set the image width and height and leave the calculation to our stylesheet. Read more about this and other brilliant techniques in Apect Ratio Boxes on CSS-Tricks.

We set an image variable for the background image in the item__img-wrap class so that we don’t have to write too many rules. This does not need to be done like that, of course, especially if you’d like support for older browsers that don’t know what variables are. Set it to the item__img directly as background-image instead, if that’s the case.

.item__img-wrap {
	--aspect-ratio: 1/1.5;
	overflow: hidden;
	width: 500px;
	max-width: 100%;
	padding-bottom: calc(100% / (var(--aspect-ratio))); 
	will-change: transform;
}

.item:first-child .item__img-wrap {
	--aspect-ratio: 8/10;
	--image: url(../img/1.jpg);
}

.item:nth-child(2) .item__img-wrap {
	width: 1000px;
	--aspect-ratio: 120/76;
	--image: url(../img/2.jpg);
}

...

The div with the background image is the one we want to move up or down on scroll, so we need to make sure that it’s taller than its parent. For that, we define an “overflow” variable that we’ll use in a calculation for the height and the top. We set this variable because we want to be able to easily change it in some modified classes. This allows us to set a different overflow to each image which changes the visual effect subtly.

.item__img {
	--overflow: 40px;
	height: calc(100% + (2 * var(--overflow)));
	top: calc( -1 * var(--overflow));
	width: 100%;
	position: absolute;
	background-image: var(--image);
	background-size: cover;
	background-position: 50% 0%;
	will-change: transform;
}

.item__img--t1 {
	--overflow: 60px;
}

.item__img--t2 {
	--overflow: 80px;
}

.item__img--t3 {
	--overflow: 120px;
}

Now, let’s do the JavaScript part. Let’s start with some helper methods and variables.

const MathUtils = {
    // map number x from range [a, b] to [c, d]
    map: (x, a, b, c, d) => (x - a) * (d - c) / (b - a) + c,
    // linear interpolation
    lerp: (a, b, n) => (1 - n) * a + n * b
};

const body = document.body;

We will need to get the window’s size, specifically it’s height, for later calculations.

let winsize;
const calcWinsize = () => winsize = {width: window.innerWidth, height: window.innerHeight};
calcWinsize();

We will also need to recalculate this value on resize.

window.addEventListener('resize', calcWinsize);

Also, we need to keep track of how much we scroll the page.

let docScroll;
const getPageYScroll = () => docScroll = window.pageYOffset || document.documentElement.scrollTop;
window.addEventListener('scroll', getPageYScroll);

Now that we have these helper functions ready, let’s get to the main functionality.
Let’s create a class for the smooth scrolling functionality.

class SmoothScroll {
    constructor() {
        this.DOM = {main: document.querySelector('main')};
        this.DOM.scrollable = this.DOM.main.querySelector('div[data-scroll]');
        this.items = [];
        [...this.DOM.main.querySelectorAll('.content > .item')].forEach(item => this.items.push(new Item(item)));
        
        ...
    }
}

new SmoothScroll();

So far we have a reference to the main element (the container that needs to become “sticky”) and the scrollable element (the one we will be translating to simulate the scroll).

Also, we create an array of our item’s instances. We will get to that in a moment.

Now, we want to update the translateY value as we scroll but we might as well want to update other properties like the scale or rotation. Let’s create an object that stores this configuration. For now let’s just set up the translationY.

constructor() {
    ...

    this.renderedStyles = {
        translationY: {
            previous: 0, 
            current: 0, 
            ease: 0.1,
            setValue: () => docScroll
        }
    };
}

We will be using interpolation to achieve the smooth scrolling effect. The “previous” and “current” values are the values to interpolate. The current translationY will be a value between these two values at a specific increment. The “ease” is the amount to interpolate. The following formula calculates our current translation value:

previous = MathUtils.lerp(previous, current, ease)

The setValue function sets the current value, which in this case will be the current scroll position.
Let’s go ahead and execute this initially on page load to set up the right translationY value.

constructor() {
    ...

    this.update();
}

update() {
    for (const key in this.renderedStyles ) {
        this.renderedStyles[key].current = this.renderedStyles[key].previous = this.renderedStyles[key].setValue();
    }   
    this.layout();
}

layout() {
    this.DOM.scrollable.style.transform = `translate3d(0,${-1*this.renderedStyles.translationY.previous}px,0)`;
}

We set both interpolation values to be the same, in this case the scroll value, so that the translation gets set immediately without the being animated. We just want the animation happening when we scroll the page. After that, we call the layout function which will apply the transformation to our element. Note that the value will be negative since the element moves upwards.

As for the layout changes, we need to:

  • set the position of the main element to fixed and the overflow to hidden so it sticks to the screen and doesn’t scroll.
  • set the height of the body in order to keep the scrollbar on the page. It will be the same as the scrollable element’s height.
constructor() {
    ...

    this.setSize();
    this.style();
}

setSize() {
    body.style.height = this.DOM.scrollable.scrollHeight + 'px';
}

style() {
    this.DOM.main.style.position = 'fixed';
    this.DOM.main.style.width = this.DOM.main.style.height = '100%';
    this.DOM.main.style.top = this.DOM.main.style.left = 0;
    this.DOM.main.style.overflow = 'hidden';
}

We also need to reset the body’s height on resize:

constructor() {
    ...

    this.initEvents();
}

initEvents() {
    window.addEventListener('resize', () => this.setSize());
}

Now we start our loop function that updates the values as we scroll.

constructor() {
    ...

    requestAnimationFrame(() => this.render());
}

render() {
    for (const key in this.renderedStyles ) {
        this.renderedStyles[key].current = this.renderedStyles[key].setValue();
        this.renderedStyles[key].previous = MathUtils.lerp(this.renderedStyles[key].previous, this.renderedStyles[key].current, this.renderedStyles[key].ease);
    }
    this.layout();
    
    // for every item
    for (const item of this.items) {
        // if the item is inside the viewport call it's render function
        // this will update the item's inner image translation, based on the document scroll value and the item's position on the viewport
        if ( item.isVisible ) {
            item.render();
        }
    }
    
    // loop..
    requestAnimationFrame(() => this.render());
}

The only new thing here is the call to the item’s render function which is called for every item that is inside the viewport. This will update the translation of the item’s inner image as we will see ahead.

Since we rely on the scrollable element’s height, we need to preload the images so they get rendered and we get to calculate the right value for the height. We are using the imagesLoaded to achieve this:

const preloadImages = () => {
    return new Promise((resolve, reject) => {
        imagesLoaded(document.querySelectorAll('.item__img'), {background: true}, resolve);
    });
};

After the images are loaded we remove our page loader, get the scroll position (this might not be zero if we scrolled the page before the last refresh) and initialize our SmoothScroll instance.

preloadImages().then(() => {
    document.body.classList.remove('loading');
    // Get the scroll position
    getPageYScroll();
    // Initialize the Smooth Scrolling
    new SmoothScroll(document.querySelector('main'));
});

So now that the SmoothScroll is covered let’s create an Item class to represent each of the page items (the images).

class Item {
    constructor(el) {
        this.DOM = {el: el};
        this.DOM.image = this.DOM.el.querySelector('.item__img');
        
        this.renderedStyles = {
            innerTranslationY: {
                previous: 0, 
                current: 0, 
                ease: 0.1,
                maxValue: parseInt(getComputedStyle(this.DOM.image).getPropertyValue('--overflow'), 10),
                setValue: () => {
                    const maxValue = this.renderedStyles.innerTranslationY.maxValue;
                    const minValue = -1 * maxValue;
                    return Math.max(Math.min(MathUtils.map(this.props.top - docScroll, winsize.height, -1 * this.props.height, minValue, maxValue), maxValue), minValue)
                }
            }
        };
    }
    ...
}

The logic here is identical to the SmoothScroll class. We create a renderedStyles object that contains the properties we want to update. In this case we will be translating the item’s inner image (this.DOM.image) on the y-axis. The only extra here is that we are defining a maximum value for the translation (maxValue). This value we’ve previously set in our CSS variable –overflow. Also, we assume the minimum value for the translation will be -1*maxVal.

The setValue function works as follows:

  • When the item’s top value (relative to the viewport) equals the window’s height (item just came into the viewport) the translation is set to the minimum value.
  • When the item’s top value (relative to the viewport) equals “-item’s height” (item just exited the viewport) the translation is set to the maximum value.

So basically we are mapping the item’s top value (relative to the viewport) from the range [window’s height, -item’s height] to [minVal, maxVal].

Next thing to do is setting the initial values on load. We also calculate the item’s height and top since we’ll need those to apply the function described before.

constructor(el) {
    ...
    
    this.update();
}

update() {
    this.getSize();
    for (const key in this.renderedStyles ) {
        this.renderedStyles[key].current = this.renderedStyles[key].previous = this.renderedStyles[key].setValue();
    }
    this.layout();
}

layout() {
    this.DOM.image.style.transform = `translate3d(0,${this.renderedStyles.innerTranslationY.previous}px,0)`;
}

getSize() {
    const rect = this.DOM.el.getBoundingClientRect();
    this.props = {
        height: rect.height,
        top: docScroll + rect.top 
    }
}

We need the same for when the window gets resized:

initEvents() {
    window.addEventListener('resize', () => this.resize());
}
resize() {
    this.update();
}

Now we need to define the render function called inside the SmoothScroll render loop function (requestAnimationFrame):

render() {
    for (const key in this.renderedStyles ) {
        this.renderedStyles[key].current = this.renderedStyles[key].setValue();
        this.renderedStyles[key].previous = MathUtils.lerp(this.renderedStyles[key].previous, this.renderedStyles[key].current, this.renderedStyles[key].ease);
    }
    this.layout();
}

This, as mentioned before, is only executed for items that are inside of the viewport. We can achieve this by using the IntersectionObserver API:

constructor(el) {
    ...

    this.observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => this.isVisible = entry.intersectionRatio > 0);
    });
    this.observer.observe(this.DOM.el);

    ...
}

And that’s it!

We hope you enjoyed this tutorial and find it useful!

How to Add Smooth Scrolling with Inner Image Animations to a Web Page was written by Mary Lou and published on Codrops.

Related Post

Collective #534

Collective #534

Gradient Magic A collection of very modern and unique CSS gradients. Check it out This content is sponsored via Syndicate…