Пара слов о себе
Привет, меня зовут Артур, и я люблю плавные интерфейсы, CSS и современные подходы. Сегодня хочу поговорить с вами о задаче, которая настигает, пожалуй, каждого фронтенд разработчика: о создании галереи.
Постановка задачи
Давайте так, возможно это и не галерея вовсе. Название не так важно, важна задача. Описать её можно так: нам нужна mobile-first свайпалка слайдов. Свайпы должны выглядеть максимально нативно и работать без съедания кадров. Хоть она и mobile-first, десктопы с их мышками стороной обходить нельзя.
Как это делалось веками
Долгие годы, уже даже десятилетия, при произнесении дизайнером слова «свайп», разработчик сразу немного грустнел, осознавая, что сейчас придется иметь дело со всякого рода вычислениями на JS, работать с touch-событиями и вспоминать как работает свойство position в CSS (десятки собесов убедили меня в том, что далеко не все понимают, как работает это на первый взгляд простое свойство).
Если дизайнер лютовал, то нужно было еще и изучать некоторые законы физики, пытаясь понять, как на JS воспроизвести эффект пружины: именно так выглядит дошедший до границы свайп на почти всех современных смартфонах.
Anyway, вооружившись законами динамики, свойствами position/left/right/transform и событиями семейства pointerevents, разработчики принимаются за дело. Со временем, один из них плюнет и загуглит «the most popular swipeable gallery on JS», второй — доведет начатое до конца и заметит, что при всех его усилиях эффекта нативности добиться не получается.
Сложность подобных подходов заключается в том, что помимо изучения всего вышеперечисленного нужно еще и быть экспертом в том, как работает рендеринг в браузере, ведь повесить все свои прекрасно описанные вычисления на pointermove — это как правило фаталити с точки зрения производительности рендеринга.
Короче, чего мы мучаемся? Мы в 2025 или где?
Современный CSS бежит на помощь
Не знаю как вы, а я, когда вижу галерею, представляю себе блок со скроллом. Скролл прекрасен тем, что он делает за нас море работы:
Обеспечивает пролистывание элементов без JS
Обеспечивает overscroll на краях без JS
При желании дает следить на прогрессом через DOM-события
Давайте попробуем имплементировать нашу галерею через горизонтальный скролл.
Галерея как блок с горизонтальным скроллом
import { Children, ReactNode } from "react";
export const Gallery = ({ children }: { children?: ReactNode; }) => {
return (
<div>
<div style={{ overflowX: "auto", display: "flex" }}>
{Children.map(children, (child, i) => (
<div style={{ width: "100%", flexShrink: 0 }} key={i}>
{child}
</div>
))}
</div>
</div>
);
};
Выглядит изумительно, как по мне, но очевидно мы тут встретимся с рядом проблем:
Видимость скроллбара.
Сквозное пролистывание: нам нужно, чтобы после свайпа происходила плавная остановка на границе между слайдами.
Убираем скроллбар
Раньше, чтобы убрать скроллбар, нужно было химичить с дополнительными HTML-элементами, свойством overflow
и хардкодом габаритов скроллбара.
Потом у нас появился замечательный ::-webkit-scrollbar-*
.
С декабря 2024-го всем этим заниматься не нужно: свойство scrollbar-width
стало доступно во всех популярных браузерах:
import { Children, ReactNode } from "react";
export const Gallery = ({ children }: { children?: ReactNode; }) => {
return (
<div>
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none"
}}
>
{Children.map(children, (child, i) => (
<div style={{ width: "100%", flexShrink: 0 }} key={i}>
{child}
</div>
))}
</div>
</div>
);
};
Убираем сквозное пролистывание
Самое интересное. 2024-й подарил нам не только массовую поддержку невидимого скроллбара, но и поддержку семейства свойств scroll-snap
. Snap в переводе с английского означает «щелчок». Этот щелчок — как раз то, что нам нужно.
Пару слов об этом семействе.
Если говорить общо, то свойства этого семейства позволяют гибко настраивать поведение скролла, особенно когда в блоке присутствуют дочерние элементы. Каждый элемент может стать промежуточной точкой остановки скролла. За это отвечает свойство scroll-snap-type
.
Помимо точек остановки можно контролировать такие вещи как расстояние (scroll-snap-padding
, scroll-snap-margin
) между промежуточными точками, выравнивание этих точек (scroll-snap-align
) и то, можно ли проскакивать точки в случае, если интенсивность жеста это подразумевает (scroll-snap-stop
).
Мы не будем использовать все свойства семейства, но если вам хочется изучить какое-то из них, рекомендую статью из MDN. Там и интерактивные примеры, и подробное описание, и информация о браузерной поддержке.
Наша задача сейчас — добавить промежуточные точки остановки скролла. Для этого воспользуемся свойствами scroll-snap-type
и scroll-snap-stop
.
Добавляем промежуточные точки остановки скролла
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always"
}}
>
{Children.map(children, (child, i) => (
<div style={{ width: "100%", flexShrink: 0 }} key={i}>
{child}
</div>
))}
</div>
Как видите, недостающая механика пролистывания галереи реализуется путем добавления буквально двух CSS-свойств.
Значение свойства scroll-snap-type: x mandatory
означает, что по оси X скролл должен останавливаться на промежуточной точке. Помимо mandatory, есть еще proximity, который делегирует решение об остановке конкретному браузеру. Здесь нам это ни к чему. Промежуточной точкой сейчас является левый край каждого дочернего элемента (слайда).
Значение свойства scroll-snap-stop: always
говорит браузеру не проглатывать слайд, даже если свайп был очень сильным.
Кастомная ширина слайда, выравнивание и зазор между слайдами
Слайды не всегда должны быть шириной с родителя, поэтому предлагаю добавить свойство для регулирования ширины. А раз даем поиграться с шириной, то и выравнивание относительно родителя тоже пригодится. Для выравнивания используем упомянутое выше CSS-свойство scroll-snap-align
. Ну и дадим регулировать расстояние между слайдами. Тут не будем ничего выдумывать и применим старый совесткий gap
:
import { Children, ReactNode } from "react";
export const Gallery = ({
children,
slideWidth = "100%",
slideAlign = "center",
slidesGap = 0
}: {
children?: ReactNode;
slideWidth?: string | number;
slideAlign?: "start" | "center" | "end";
slidesGap?: string | number;
}) => {
return (
<div>
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always",
gap: slidesGap
}}
>
{Children.map(children, (child, i) => (
<div
style={{
width: slideWidth,
scrollSnapAlign: slideAlign,
flexShrink: 0,
}}
key={i}
>
{child}
</div>
))}
</div>
</div>
);
};
Прошу обратить внимание на то, что свежеиспеченное свойство
scroll-snap-align
вместо top, left, bottom и right принимает значения start и end. Это так называемые logical values: они компактнее и позволяют не думать о перевернутых интерфейсах (например, интерфейсах на арабском языке).Если интересно, что за logical values такие, то можете почитать статью на CSS Tricks или посмотреть мой вебинар.
Даем менять начальный слайд
Ладно, предлагаю немного покодить. Разрабы по-любому захотят рисовать галерею с первоначальным слайдом, отличным от первого. Тут уж без TS не обойтись:
import { Children, ReactNode, useEffect, useRef } from "react";
export const Gallery = ({
children,
slideWidth = "100%",
slideAlign = "center",
slidesGap = 0,
initialSlideIndex = 0,
}: {
children?: ReactNode;
slideWidth?: string | number;
slideAlign?: "start" | "center" | "end";
slidesGap?: string | number;
initialSlideIndex?: number;
}) => {
const scrollContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
if (initialSlideIndex > 0) {
scrollContainer.current?.children[initialSlideIndex].scrollIntoView({
behavior: "instant",
block: "nearest",
});
}
}, []);
return (
<div>
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always",
gap: slidesGap
}}
ref={scrollContainer}
>
{Children.map(children, (child, i) => (
<div
style={{
width: slideWidth,
scrollSnapAlign: slideAlign,
flexShrink: 0,
}}
key={i}
>
{child}
</div>
))}
</div>
</div>
);
};
Давайте по-порядку
useRef
и useEffect
— сущности из мира React, останавливаться надолго на них не хочется. Через useRef
получаем ссылку на DOM-элемент, через useEffect
— запускаем сниппет в коллбэке при первом рендере компонента.
Из интересного тут метод DOM-элемента scrollIntoView
. Это метод, при запуске которого родитель проскроллит себя до элемента.
Важно: метод необходимо запускать на дочернем элементе, а не на родителе.
Свойства у метода не менее интересные.
behavior
позволяет регулировать тип скролла: он может быть или мгновенным, или плавным. Для решения нашей задачи нужен именно мгновенный.
block
регулирует то, к каким родительским элементам будет применен вызов. Если не указать значение nearest, мы увидим, что при вызове отработает не только скролл галереи, но и скролл документа. А это нам не нужно.
Со всеми возможностями и нюансами этого метода можно ознакомиться в статье от MDN.
Поддержка десктопа
В начале статьи я писал о том, что хоть мы и mobile-first, десктопы нельзя списывать со счетов. Там зачастую нет удобного способа поскроллить вертикально, да и там где так можно, это всё равно не считается привычным UX-паттерном. На десктопах балом правят клики мышкой. Поэтому предлагаю добавить стрелки по бокам галереи.
Давайте сделаем так, чтобы видимость стрелок была опциональной. Так же предлагаю отдать на откуп пользователям компонента внешний вид стрелок. Зададим им только базовые свойства.
import { Children, ReactNode, useEffect, useRef, CSSProperties } from "react";
type Arrow = {
content: ReactNode;
inlineOffset: number | string;
};
const arrowStyles: CSSProperties = {
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
};
export const Gallery = ({
children,
slideWidth = "100%",
slideAlign = "center",
slidesGap = 0,
initialSlideIndex = 0,
arrows,
}: {
children?: ReactNode;
slideWidth?: string | number;
slideAlign?: "start" | "center" | "end";
slidesGap?: string | number;
initialSlideIndex?: number;
arrows?: [Arrow, Arrow];
}) => {
const scrollContainer = useRef<HTMLDivElement>(null);
const activeSlideIndex = useRef<number>(initialSlideIndex);
useEffect(() => {
if (initialSlideIndex > 0) {
scrollContainer.current?.children[initialSlideIndex].scrollIntoView({
behavior: "instant",
block: "nearest",
});
}
}, []);
const scrollTo = (slideIndex: number) => {
if (activeSlideIndex.current !== slideIndex) {
scrollContainer.current?.children[slideIndex].scrollIntoView({
behavior: "smooth",
block: "nearest",
});
activeSlideIndex.current = slideIndex;
}
};
return (
<div>
<div style={{ position: "relative" }}>
{arrows && (
<div
style={{
...arrowStyles,
insetInlineStart: arrows[0].inlineOffset,
}}
onClick={() => {
if (activeSlideIndex.current > 0) {
scrollTo(activeSlideIndex.current - 1);
}
}}
>
{arrows[0].content}
</div>
)}
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always",
gap: slidesGap,
position: "relative",
zIndex: 1,
}}
ref={scrollContainer}
>
{Children.map(children, (child, i) => (
<div
style={{
width: slideWidth,
scrollSnapAlign: slideAlign,
flexShrink: 0,
}}
key={i}
>
{child}
</div>
))}
</div>
{arrows && (
<div
style={{
...arrowStyles,
insetInlineEnd: arrows[1].inlineOffset,
}}
onClick={() => {
if (activeSlideIndex.current + 1 < Children.count(children)) {
scrollTo(activeSlideIndex.current + 1);
}
}}
>
{arrows[1].content}
</div>
)}
</div>
</div>
);
};
Ощущение, как будто добавилось много кода, но на самом деле он прост как три копейки:
Даем возможность пробрасывать свойство
arrows
, которое является массивом с двумя элементами. Каждый из элементов — объект, содержащий разметку стрелки и её отступ от края.Выравнивание по-вертикали и прочие общие свойства мы указали в
arrowStyles
Запоминаем индекс текущего слайда в рефе
activeSlideIndex
. Я использую ref, а не state, потому что не хочу, чтобы изменения этого значения приводили к ненужной перерисовке.Пишем функцию
scrollTo
, которая скроллит галерею к указанному в аргументе индексу. Внутри можно увидеть переиспользование упомянутого выше методаscrollIntoView
, но уже с переданным параметром{ behavior: “smooth” }
для плавного пролистывание при клике по стрелке.В общем-то всё ?
Будущее
Во всем, что касается скролла, последние годы можно увидеть бурный движ. Развиваются как CSS, так и JS. Рассмотрим пару, на мой взгляд, мощных нововведений, которые вскоре появятся во всех современных браузерах.
Удобные события
Очевидно, что рано или поздно нашему компоненту придется добавить обработчики событий типа onChange
. В целом, задача легко решается и с текущими арсеналом браузерных событий. У нас как минимум есть onscroll
. Но каждый, кто работал с этим событием знает, что придется прибегать к так называемому дебаунсу, чтобы не дергать обработчик по несколько десятков раз в секунду.
Так вот, вскоре в стандарт HTML вероятно добавят события onscrollsnapchange
и onscrollend
. Эти события позволят решить нашу задачу в пару строк. Поэтому ждем
Анимация скролла
Это вообще чума. Стандарт, который уже поддержан в браузерах на движке Chromium, позволяет прям в CSS описывать изменения свойств относительно позиции скролла. То есть когда стандарт заработает во всех браузерах, можно будет намутить бесконечное количество анимаций пролистывания без единой строчки JS-кода.
Крайне рекомендую посмотреть на примеры и доку. Там всё очень интересно.
Заключение
В заключении хочется сказать, что я безумно рад тому, как стремительно и масштабно развиваются веб стандарты. Рекомендую следить за обновлениями и при столкновении с очередной задачей применять нативные технологии, которые зачастую позволяют создать собственное красивое, компактное, производительное решение.
Рассмотренное в статье решение обошлось без зависимостей и заняло чуть больше 100 строк кода.
Комментарии (6)
maXimus2031
24.01.2025 12:59делал такое
на мобиле карточки типа обычных row/cols смотрятся очень неудобно, и scroll-snap экономит место и улучшает пользовательский опыт
wadowad
24.01.2025 12:59Пару лет назад тоже пришлось написать подобное решение (но только на jQuery), чтобы на мобильном свайп был плавный и реагировал мгновенно, потому что slick slider на телефоне заметно притормаживал.
nerolinker
24.01.2025 12:59Ничего серьезного не делаю на смартфоне. За смерть веба еще ответите зумеры.
dom1n1k
24.01.2025 12:59Выглядит вроде прикольно, но уж очень высокая чувствительность, слайды перелистываются слишком легко. Настолько, что порождает сайд-эффект – долистываю до конца, пытаюсь листать дальше, оно естественно не дает, но в момент отрыва пальца видимо происходит микросдвиг касания в обратную сторону, и галерея самопроизвольно перелистывается на предпоследний слайд.
Ещё надо посмотреть, не будет ли скролл протекать в родительский элемент. Такое встречается в некоторых галереях, и это одна из самых бесячих вещей, убивающих ощущение нативности. Когда свайпаешь в сторону, палец естественно не движется абсолютно горизонтально, есть какой-то небольшой наклон. И вот это небольшое вертикальное смещение вызывает вертикальное дрожание страницы целиком.
anoneko
>mobile-first
Весь веб уже испоганили редизайном под мерзкие телефоны, давайте продолжим, ведь пользователи десктопов - не люди.
ArthurSupertramp Автор
Дальше заголовка не читали, да? :)