Как создается плавная прокрутка страницы с анимацией изображений

Посмотреть демонстрацию Скачать исходный код

В этой статье вы узнаете, как добавить в HTML-разметку плавную прокрутку с дополнительными эффектами анимации для изображений.

Для чего добавлять подобную ​​плавную прокрутку на веб-страницу? При скроллинге часто возникают проблемы с отображением контента. При прокрутке изображений могут возникать резкие скачки. Чтобы избежать его появления, можно анимировать сам контент, передвигая его вверх или вниз, вместо использования «нативной» прокрутки.

Как создается плавная прокрутка страницы сайта

Пример плавной прокрутки с эффектом перекоса показывает, как работает этот способ. Для контейнера, в котором содержится контент, установлено position: fixed и overflow: hidden. Благодаря этому дочерний элемент контейнера может перемещаться.

Раздел body веб-страницы получает высоту содержимого, чтобы отображалась полоса скроллинга. При прокрутке страницы зафиксированный контейнер останется на месте, когда его внутреннее содержимое передвигается в результате анимации. Это простой способ реализации плавной прокрутки.

В нашем примере мы будем использовать следующий HTML-код.

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

Основной элемент будет «закрепленным» контейнером, в то время как div data-scroll будет перемещаться.

Внутренняя анимация изображения

Для внутренней анимации понадобится само изображение и родительский контейнер, для которого установлено свойство overflow: hidden. Мы будем перемещать фоновое изображение, размещенное в div, вверх или вниз во время прокрутки. При этом изображение в блоке div должно быть больше, чем его родитель:

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

Установим стили для этих элементов. Мы будем использовать отступ вместо свойства высоты, чтобы сохранить правильное соотношение сторон для внутреннего блока div с изображением. Для этого мы используем медиа-функцию aspect ratio.

Зададим переменную image для фонового изображения в классе item__img-wrap, чтобы не писать слишком много кода.

.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);
}

...

При прокрутке мы будем перемещать div с фоновым изображением. Поэтому он должен иметь большую высоту, чем его родитель. Для этого определим переменную overflow, которую будем использовать при расчете высоты и верхней точки в нескольких классах. Это позволяет устанавливать разное значение свойства overflow для каждого изображения, которое изменяет визуальный эффект.

.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;
}

Теперь займемся JavaScript. Начнем с вспомогательных методов и переменных.

const MathUtils = {
    // сопоставляем координаты x из диапазона от [a, b] до [c, d]
    map: (x, a, b, c, d) => (x - a) * (d - c) / (b - a) + c,
    // линейная интерполяция
    lerp: (a, b, n) => (1 - n) * a + n * b
};

const body = document.body;

Для последующих вычислений нужно получить высоту окна.

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

А также пересчитать это значение при изменении размера.

window.addEventListener('resize', calcWinsize);

Кроме этого нам нужно отслеживать то, на какое количество пикселей перемещается страница во время скроллинга.

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

Теперь создадим класс для функции плавной прокрутки.

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();

У нас есть ссылка на контейнер, который должен стать «липким», и прокручиваемый элемент. Он будет перемещаться для имитации прокрутки.

Теперь нужно обновить значение translateY при прокрутке. А также и другие свойства, такие как scale или rotation. Создадим объект, который хранит эту конфигурацию. Но пока просто настроим translateY.

constructor() {
    ...

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

Мы будем использовать интерполяцию для достижения эффекта плавной прокрутки. Значения previous и current  являются значениями для интерполяции. Текущее значение translationY будет значением между этими точками с определенным приращением.

ease — это сумма для интерполяции. Приведенная ниже формула вычисляет текущее значение перемещения:

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

Функция setValue устанавливает значение, которое будет текущей позицией прокрутки. Выполним перечисленные выше операции при загрузке страницы, чтобы установить правильное значение translationY.

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)`;
}

Мы устанавливаем оба значения интерполяции одинаковыми. В данном случае это координаты прокрутки. Благодаря этому анимация происходит при прокрутке страницы. После этого мы вызываем функцию layout, которая применит преобразование к элементу. Обратите внимание, что значение будет отрицательным, так как элемент перемещается вверх.

Для изменения разметки нужно:

  • Установить в фиксированное положение главный элемент, а свойству overflow задать значение hidden, чтобы содержимое прилипало к экрану и не прокручивалось.
  • Установить высоту элемента body , чтобы полоса прокрутки оставалась на странице. Она будет такой же, как высота прокручиваемого элемента.
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';
}

Нам также нужно сбросить увеличение высоты body при изменении размера.

constructor() {
    ...

    this.initEvents();
}

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

После этого запускаем в цикле функцию, которая обновляет значения при прокрутке.

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 (const item of this.items) {
        // если элемент находится в пределах окна просмотра, вызываем функцию рендеринга
        if ( item.isVisible ) {
            item.render();
        }
    }
    
    // цикл…
    requestAnimationFrame(() => this.render());
}

Вызов функции рендеринга запускается для каждого элемента, который находится внутри области просмотра. Это обновит положение элемента изображения.

Нужно предварительно загрузить изображения, чтобы они отображались, и мы могли рассчитать правильное значение высоты. Для этого используем imagesLoaded:

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

После загрузки изображений мы удаляем класс loading, получаем позицию прокрутки и инициализируем экземпляр SmoothScroll.

preloadImages().then(() => {
    document.body.classList.remove('loading');
    // Получаем позицию прокрутки
    getPageYScroll();
    // Инициализируем SmoothScroll
    new SmoothScroll(document.querySelector('main'));
});

Теперь создадим класс Item для представления каждого элемента страницы (изображений).

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)
                }
            }
        };
    }
    ...
}

Сначала создаем объект renderedStyles, содержащий свойства, которые нужно обновить. В этом случае мы перемещаем изображение (this.DOM.image) по оси Y. Затем определяем максимальное значение для перемещения (maxValue). Это значение мы предварительно установили в переменной CSS overflow. Также мы предполагаем, что минимальное значение для перемещения равно -1*maxVal.

Функция setValue работает следующим образом:

  • Когда верхняя позиция элемента относительно области просмотра равна высоте окна, для перемещения устанавливается минимальное значение.
  • Когда верхняя позиция элемента относительно области просмотра больше или равно его высоте (элемент только что вышел из области просмотра), для перемещения устанавливается максимальное значение.

После этого нужно установить начальные значения при загрузке. Мы также вычисляем высоту и верхнюю точку элемента, которые нам понадобятся для применения функции, описанной ранее.

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 
    }
}

Когда размер окна изменяется:

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

После этого определим функцию рендеринга, вызываемую внутри функции  SmoothScroll (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();
}

Этот код выполняется только для элементов, которые находятся внутри области просмотра. Для этого используем API IntersectionObserver.

constructor(el) {
    ...

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

    ...
}

Это все! Надеемся, что это руководство помогло вам реализовать плавную прокрутку изображений на собственном сайте.

Данная публикация является переводом статьи «The Many Ways to Include CSS in JavaScript Applications» , подготовленная редакцией проекта.

Меню