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

A tutorial on how to add a smooth scrolling effect to a website and also animate the images that enter the viewport.

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:

<body class="loading">
	<main>
		<div data-scroll>
			<!-- ... --->
		</div>
	</main>
</body>

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:

<div class="item">
	<div class="item__img-wrap"><div class="item__img"></div></div>
	<!-- ... --->
</div>

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(https://tympanus.net/Tutorials/SmoothScrollAnimations/img/1.jpg);
}

.item:nth-child(2) .item__img-wrap {
	width: 1000px;
	--aspect-ratio: 120/76;
	--image: url(https://tympanus.net/Tutorials/SmoothScrollAnimations/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 an animation. 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!

Tagged with:

Manoela Ilic

Manoela is the main tinkerer at Codrops. With a background in coding and passion for all things design, she creates web experiments and keeps frontend professionals informed about the latest trends.

Stay up to date with the latest web design and development news and relevant updates from Codrops.

Feedback 31

Comments are closed.
  1. Do not change scroll behaviour, it’s detrimental to user experience because they want and expect native behaviour

    > Imagine if your car changed its steering or throttle sensitivity when you entered a certain town. That’s what it feels like to your visitors.

    • that’s a bit dramatic. i would argue it’s not inherently changing scroll behavior, but simply subtly modifying the experience to make it smoother and more enjoyable. changing how a car works would result in someone crashing and possibly dying. a minor tweak to scroll behavior on a website isn’t going to cause anyone harm.

      beautiful effect! thank you for the article

    • Although, I would argue, altering the scroll behavior in this way could have a negative impact towards users who are motion sensitive. Myself, being one of them. There is a prefers-reduced-motion media feature that could be incorporated, perhaps? Regardless, nice demo as usual Mary Lou.

    • I completely disagree. For me it adds a layer of experience, and a very pleasant one at that. The only time I wouldn’t want this would be on mobile.

    • Absolutely agree with you here.

      Overriding default browser behavior should never, under any circumstances, happen without the explicit request from the user.

      An effect like this is beautiful for some people, yes, but it should be gated behind a checkbox, as to not harm the experience for users who cannot deal with motion.

      Never force pizzaz over accessibility.

    • Few thoughts after reading the comments:

      1. I agree that this effect is not suited for mobile. Just as with every other design choice, it depends on the context. But simply stating “scrolljacking is bad”, is too simplistic for todays standards.

      2. Users, the web, and everything in between including specs and capability is always changing. Of course, the fundamentals stay the same — one of which is experience. There are leaders in this arena, many of which have sites on awwwards and the like, and its obvious many of them see the value in this type of design choice. If you think you’re better, well, I’d love to see some work behind those definitive opinions. And the only thing I would personally encourage is that ‘developers’ try to become less delusional and more capable. This requires humility and the awareness to examine their own skill level.

      3. If you’re a capable developer, you can achieve BOTH accessibility and bolder design choices. Simple as that.

      4. One thing that’s indisputable is that Mary provided an amazing source of information to learn from.

    • Hi Scott, can you share a bit more about what browser you are using and if you have any errors showing in the console? Cheers, ML

    • Found the bug! Variables and relative URLs didn’t resolve well in Safari. Absolute URLs do the trick. Thank you all for your feedback! Cheers, ML

  2. Scroll jacking (one that prevents fast swiping up or down a page on mobile) IMHO is an anti-pattern.

  3. Sadly doesn’t show iamges on Safari (MacOS):
    [Error] Failed to load resource: the server responded with a status of 404 (Not Found) (4.jpg, line 0)

    Look cool on Chrome though.

    • Thanks Johan, turns out the variables for the images didn’t resolve well on Safari. I’ve corrected it now 🙂 Cheers, ML

  4. It’s nice and all, but I’d rather make a javascript snippet to change background-position-y according to the top position of the image (which would be a background-image with padding-bottom instead) in relation to the viewport. That’d be A LOT lighter on the processor & with bigger compatibility, the bottleneck being just displaying the image without animations. 🙂

  5. Please stop scrolljacking and encouraging scrolljacking. When you make your website scroll differently than every other scrollable view on a user’s device, you’re creating an unfriendly, unfamiliar experience. At best a user doesn’t notice, and at worst it feels awkward, uncomfortable and slow.

  6. For those who want to get this same effect without the ‘scroll jacking’ – I think you can just change the easing values to 1…

  7. I have to advocate developers to not follow this example.

    Scroll-jacking causes serious issues for some users and independently, some devices too.

    Nothing is more annoying then watching a website with subtle effects suddenly start janking around because the js engine is garbage collecting preventing the events from being handled, regardless of how awesome the subtle effects appear.

    There are ways around it of course by mixing your scroll/resize events with `requestAnimationFrame` and a debounce function but there are still other issues not addressed with this approach. For instance, one issue that web-devs currently face is the fact that browsers ignore the `requestAnimationFrame` spec and lock framerates to 60fps on higher fps displays because JS can’t run fast enough, or at least fast enough without using too much resources, resulting in the website suddenly looking stuttery compared to every other website out there that relies on standard scrolling and css-animation behaviour.

    That said, there is a way to achieve the demo (sans smooth-scroll) running as fast and jank-free as possible by only using css. It’s essentially a two layer parallax distortion that can be implemented using `transform: translate3d` and `perspective` with some creative masking/covering-up on the closest layer to frame the images that are further away.

    Maybe I’ll make a codepen if there’s some interest in it.

    • Meantime, websites with effects like these are the ones wining awards. You know, you can also build a website with a white background and an h1 where it reads in black “Hello my name is Jayden”

    • I’d love to see a well performing css-only solution without all the scroll-hijacking.
      A working codepen would be great 🙂

  8. Hey. I just tested it on my phone and its a bit laggy. Anyway I really love the effect for desktop devices, but is there a way to deactivate both on mobile devices?

  9. Wow, this is a killer use of scrolling. I can remember laying out just such a program with tables and it was miserable!

  10. I love that it still relies on the native page scrolling, it basically just replaces the rendering part. Many difficulties that would get introduced with using the mousewheel event don’t come up.

  11. While clever, I find this approach pretty unsuitable for smartphones, as most mobile browsers automatically trigger the `resize` event when the user scrolls up and down and the UI changes, making the `window.innerHeight` value change.

    As a result, elements just jump around whenever the UI chrome is toggled. Just try it out on an iPhone: the animation is smooth, but things get messy when you change the direction of your scroll.

  12. Thank you for sharing this Mary Lou, i was wondering how it works, now i have a good idea.

  13. Hi, there seems to be a bug with the data scroll container. When you scroll down and back up again the transform3d doesn’t stop ticking. When you scroll to the bottom it does. I checked the smooth scroll with skew effect and this works okay when you scroll back to the top. hat do you think is causing this?