Tutorial: как прокрутить страницу к элементу

Начнём с разметки — она довольно простая. У нас будет несколько секций, по которым мы будем прокручивать страницу, и две кнопки, запускающие анимацию прокрутки при клике: одна использует id, другая — React ref в качестве цели.
Приложение
// App.js
import React from "react";
import "./styles.css";
import ScrollToButton from "./components/ScrollToButton";
import Section from "./components/Section";
const sections = ["intro", "description", "contact", "footer"];
export default function App() {
const descriptionRef = React.useRef(null);
return (
<div className="App">
<h1>Hello World.</h1>
<h2>Нажмите на кнопку, чтобы увидеть магию!</h2>
<ScrollToButton toId="contact">Прокрутить к контакту!</ScrollToButton>
<Section title={sections[0]} />
<Section ref={descriptionRef} title={sections[1]} />
<Section id={sections[2]} title={sections[2]}>
<ScrollToButton duration={1500} toRef={descriptionRef}>
Прокрутить к описанию!
</ScrollToButton>
</Section>
<Section title={sections[3]} />
</div>
);
}Кнопка
Кнопка должна принимать либо id элемента, либо React ref как цель для прокрутки. Также можно задать длительность анимации. Например, если нужно прокрутить всего на одну секцию вверх, 3 секунды — это слишком долго, верно? Поэтому поддерживается параметр duration. Последний проп — children, в нашем случае это просто текст.
// ScrollToButton.jsx
import React from "react";
import { scrollTo } from "../utils";
const ScrollToButton = ({ toId, toRef, duration, children }) => {
const handleClick = () => scrollTo({ id: toId, ref: toRef, duration });
return <button onClick={handleClick}>{children}</button>;
};
export default ScrollToButton;Компонент Section — это простой контейнер, который принимает id или ref (для демонстрации), а также title и children. Обратите внимание на использование передачи ref (ref forwarding) — это правильный способ передать ref в DOM.
// Section.jsx
import React from "react";
const ScrollToButton = React.forwardRef(({ id, title, children }, ref) => (
<section ref={ref} id={id}>
<h2>{title}</h2>
{children}
</section>
));
export default ScrollToButton;Стили
Уже что-то есть, но выглядит скучновато. Добавим немного стиля! Каждой секции задана высота 100% окна (viewport), чтобы можно было наглядно увидеть прокрутку без привязки к реальному содержимому.
/* styles.css */
.App {
font-family: sans-serif;
text-align: center;
}
section {
height: 100vh;
border-bottom: 1px solid blue;
padding-top: 30px;
}
button {
cursor: pointer;
background: transparent;
color: #0000ff;
border: none;
border-bottom: 2px solid #0000ff;
font-weight: bold;
font-size: 1rem;
padding-left: 0px;
padding-right: 0px;
padding-bottom: 5px;
}Обёртка (wrapper)
Теперь добавим обёртку для нашей утилиты. Мы решили поддерживать и id, и React ref в качестве цели, поэтому функция определяет тип ссылки и вызывает основную функцию с нужным элементом и начальной позицией.
Также проверяется валидность переданного элемента — если он некорректный, выводится ошибка в консоль.
Используется значение duration по умолчанию, чтобы не задавать его каждый раз.
// scrollTo.js
import { animateScroll } from "./animateScroll";
const logError = () =>
console.error(
Некорректный элемент. Вы уверены, что передали id или React ref?
);
const getElementPosition = (element) => element.offsetTop;
export const scrollTo = ({ id, ref = null, duration = 3000 }) => {
// текущая позиция скролла до клика
const initialPosition = window.scrollY;
// определяем тип ссылки
const element = ref
? ref.current
: id
? document.getElementById(id)
: null;
if (!element) {
logError();
return;
}
animateScroll({
targetPosition: getElementPosition(element),
initialPosition,
duration,
});
};Анимация
Сначала определим функцию сглаживания (easing). Здесь используется easeOutQuart, так как она создаёт естественное замедление анимации. При желании можно выбрать другую функцию.
Эта функция принимает значение прогресса анимации от 0 до 1.
Ключевой момент — использование requestAnimationFrame. Раньше для этого применяли циклы и throttling, что было плохо для производительности. requestAnimationFrame старается обеспечивать ~60 FPS и синхронизируется с частотой обновления экрана.
// animateScroll.js
const pow = Math.pow;
// функция easing (замедление к концу)
function easeOutQuart(x) {
return 1 - pow(1 - x, 4);
}
export function animateScroll({ targetPosition, initialPosition, duration }) {
let start;
let position;
let animationFrame;
const requestAnimationFrame = window.requestAnimationFrame;
const cancelAnimationFrame = window.cancelAnimationFrame;
// максимальная прокрутка
const maxAvailableScroll =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const amountOfPixelsToScroll = initialPosition - targetPosition;
function step(timestamp) {
if (start === undefined) {
start = timestamp;
}
const elapsed = timestamp - start;
// прогресс от 0 до 1
const relativeProgress = elapsed / duration;
const easedProgress = easeOutQuart(relativeProgress);
// вычисляем новую позицию
position =
initialPosition -
amountOfPixelsToScroll * Math.min(easedProgress, 1);
window.scrollTo(0, position);
// остановка при достижении максимума
if (
initialPosition !== maxAvailableScroll &&
window.scrollY === maxAvailableScroll
) {
cancelAnimationFrame(animationFrame);
return;
}
// продолжаем до завершения
if (elapsed < duration) {
animationFrame = requestAnimationFrame(step);
}
}
animationFrame = requestAnimationFrame(step);
}И это всё! Можно расширить решение, например, добавить горизонтальную прокрутку или поддержку прокрутки внутри отдельных контейнеров. Но основной принцип теперь понятен, так что такие доработки будет несложно реализовать при необходимости.