“Setup took an afternoon and the first report landed in my inbox the next morning. I have not opened the old spreadsheet since.”
Карусель отзывов на React, на нативной привязке прокрутки
Блок отзывов на любом маркетинговом сайте: одна карточка в центре, соседние наклонены позади неё, точки снизу, свайп или клик — к следующей. Слайдер-библиотеки продают ровно это. У браузера уже есть обе половины — scroll-snap-type для остановки и анимация по прокрутке для наклона, — а эта библиотека добавляет то, что не относится ни к одной из них: знание, какая карточка текущая.
Свайпайте, перетаскивайте или пользуйтесь стрелками и точками — ряд каждый раз останавливается на карточке.
Редактировать этот пример вживую в Storybook
Для чего здесь на самом деле слайдер-библиотека
Карусель отзывов — каноническая установка Swiper: три видимые карточки, средняя плоская, точки пагинации, автовоспроизведение. Каждое из этого — либо CSS, либо несколько строк на API этой библиотеки:
- Остановка на карточке — это
scroll-snap-type: x mandatoryна ряде иscroll-snap-align: centerна элементах. Прокрутка касанием, колесом и клавиатурой остаётся нативной и сама замедляется до карточки. - Наклон — анимация по прокрутке на каждой карточке, так что угол следует за положением прокрутки пиксель в пиксель, и JavaScript ничего не измеряет. Chromium и Safari 26 рисуют его; Firefox пока держит за флагом и показывает плоские карточки, а правило защищено
@supports. - Точкам и стрелкам нужно знать текущую карточку:
onScrollнаходит ближайшую к центру ряда, аscrollToItem(el, 'smooth', 'center')шагает к соседу или прыгает к карточке точки, по построению попадая в точку привязки.
Единственное, что надо сделать правильно: перетаскивание
Контейнер с обязательной привязкой привязывает каждую программную запись в scrollLeft, поэтому рецепт перетаскивания мышью как есть дёргался бы от карточки к карточке. Пример привязки прокрутки показывает решение: выключить привязку на время жеста, при отпускании плавно доехать до ближайшей карточки через scrollToItem и вернуть привязку CSS, когда движение успокоится, — включи раньше, и будет прыжок вместо плавного хода. Касанию ничего из этого не нужно: ряд — настоящий контейнер прокрутки.
Автовоспроизведение, цикл и что остаётся вашим
Автовоспроизведение — таймер, вызывающий тот же scrollToItem, с паузой при наведении, фокусе и уменьшенном движении. Бесконечный цикл тоже сочетается: телепорт сдвигает на длину цикла, то есть на целое число точек привязки. Карточки — ваши компоненты: аватар, оценка, цитата, логотип, — а геометрия — три пользовательских свойства: ширина карточки, отступ и наклон.
Паттерн в минимальном виде
Привязка и наклон — в таблице стилей; стрелки и точки — одно измерение и один scrollToItem. Демо выше — это плюс карточки.
function TestimonialCarousel({ reviews }: { reviews: Review[] }) {
const [active, setActive] = React.useState(0);
return (
<ScrollMenu
scrollContainerClassName="snap-rail"
itemClassName="snap-slot"
onScroll={(api) => setActive(nearestIndex(api, reviews))}
LeftArrow={<Arrow id={reviews[active - 1]?.id} />}
RightArrow={<Arrow id={reviews[active + 1]?.id} />}
>
{reviews.map((review) => (
<ReviewCard itemId={review.id} key={review.id} {...review} />
))}
</ScrollMenu>
);
}
// The card whose center is closest to the rail's center.
function nearestIndex(api: publicApiType, reviews: Review[]) {
const rail = api.scrollContainer.current!;
const middle = rail.scrollLeft + rail.clientWidth / 2;
const distances = reviews.map(({ id }) => {
const slot = api.getItemElementById(id) as HTMLElement;
return Math.abs(slot.offsetLeft + slot.offsetWidth / 2 - middle);
});
return distances.indexOf(Math.min(...distances));
}
// Arrows step one card: the neighbour of the centered one, centered.
function Arrow({ id }: { id?: string }) {
const api = React.useContext<publicApiType>(VisibilityContext);
const step = () => {
const el = id && api.getItemElementById(id);
if (el) api.scrollToItem(el, 'smooth', 'center');
};
return <button disabled={!id} onClick={step}>→</button>;
}
/* .snap-rail — the browser lands every swipe on a card:
scroll-snap-type: x mandatory;
padding-inline: calc(50% - var(--card) / 2);
.snap-slot — scroll-snap-align: center;
The fan is the snap.css panel on the homepage. */Или установите как компонент shadcn
Элемент реестра snap-carousel поставляет весь паттерн — привязку, веер, стрелки, точки и перетаскивание, укладывающееся на ближайшую карточку, — как компонент со стилями Tailwind в вашем components/ui/. Карточки приносите свои:
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/snap-carousel.json