Перейти к содержимому

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

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

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

Комментарии

Комментариев пока нет.

Войдите, чтобы оставить комментарий.