Реализация Scroll Snap для секционного скролла на сайте
CSS Scroll Snap — нативный механизм "примагничивания" скролла к определённым позициям без JavaScript. Работает во всех современных браузерах, производительно (браузер обрабатывает на уровне compositor), не требует библиотек. Применяется для лендингов с посекционным скроллом, горизонтальных слайдеров, каруселей.
Базовая реализация
/* Вертикальный секционный скролл */
.scroll-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory; /* обязательная привязка */
scroll-behavior: smooth;
/* Важно: убирает momentum scrolling на iOS, нужен дополнительный код */
-webkit-overflow-scrolling: touch;
}
.section {
height: 100vh;
scroll-snap-align: start; /* привязка к началу секции */
scroll-snap-stop: always; /* нельзя перелететь через секцию */
}
/* Горизонтальный слайдер */
.slider-container {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
gap: 16px;
padding: 0 16px;
/* Скрываем scrollbar визуально */
scrollbar-width: none;
}
.slider-container::-webkit-scrollbar {
display: none;
}
.slide {
flex-shrink: 0;
width: 300px;
scroll-snap-align: start;
}
// components/SectionScroll.tsx
export function SectionScroll({ sections }: { sections: React.ReactNode[] }) {
return (
<div
className="h-screen overflow-y-scroll"
style={{
scrollSnapType: 'y mandatory',
scrollBehavior: 'smooth',
}}
>
{sections.map((section, i) => (
<section
key={i}
className="h-screen flex items-center justify-center"
style={{
scrollSnapAlign: 'start',
scrollSnapStop: 'always',
}}
>
{section}
</section>
))}
</div>
)
}
Навигационные точки с отслеживанием активной секции
// hooks/useActiveSectionScroll.ts
import { useEffect, useRef, useState } from 'react'
export function useActiveSectionScroll(sectionCount: number) {
const [activeIndex, setActiveIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, clientHeight } = container
const index = Math.round(scrollTop / clientHeight)
setActiveIndex(index)
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [])
const scrollToSection = (index: number) => {
const container = containerRef.current
if (!container) return
container.scrollTo({
top: index * container.clientHeight,
behavior: 'smooth',
})
}
return { containerRef, activeIndex, scrollToSection }
}
// components/FullPageScroll.tsx
import { useActiveSectionScroll } from '../hooks/useActiveSectionScroll'
const sections = [
{ id: 'hero', label: 'Главная', color: 'bg-blue-600' },
{ id: 'about', label: 'О нас', color: 'bg-purple-600' },
{ id: 'services', label: 'Услуги', color: 'bg-indigo-600' },
{ id: 'contact', label: 'Контакты', color: 'bg-pink-600' },
]
export function FullPageScroll() {
const { containerRef, activeIndex, scrollToSection } =
useActiveSectionScroll(sections.length)
return (
<div className="relative">
{/* Навигационные точки */}
<nav className="fixed right-6 top-1/2 -translate-y-1/2 z-50 flex flex-col gap-3">
{sections.map((section, i) => (
<button
key={section.id}
onClick={() => scrollToSection(i)}
className={`
w-3 h-3 rounded-full border-2 border-white transition-all duration-300
${activeIndex === i ? 'bg-white scale-125' : 'bg-transparent'}
`}
aria-label={`Перейти к ${section.label}`}
/>
))}
</nav>
{/* Контейнер секций */}
<div
ref={containerRef}
className="h-screen overflow-y-scroll"
style={{ scrollSnapType: 'y mandatory' }}
>
{sections.map((section, i) => (
<section
key={section.id}
id={section.id}
className={`h-screen flex items-center justify-center ${section.color}`}
style={{ scrollSnapAlign: 'start', scrollSnapStop: 'always' }}
>
<h2 className="text-5xl font-bold text-white">{section.label}</h2>
</section>
))}
</div>
</div>
)
}
scroll-snap-type: proximity vs mandatory
/* mandatory — всегда привязывается, нельзя остановиться между */
scroll-snap-type: y mandatory;
/* proximity — привязывается только если скролл остановился близко к точке привязки */
scroll-snap-type: y proximity;
Для лендингов рекомендуется mandatory. Для длинных страниц с контентом разной высоты — proximity, иначе пользователь не сможет прокрутить до середины раздела.
Горизонтальная карусель с управлением
// components/CardCarousel.tsx
import { useRef } from 'react'
export function CardCarousel({ cards }: { cards: React.ReactNode[] }) {
const trackRef = useRef<HTMLDivElement>(null)
const scrollBy = (direction: 'prev' | 'next') => {
const track = trackRef.current
if (!track) return
const cardWidth = (track.firstElementChild as HTMLElement)?.offsetWidth ?? 300
track.scrollBy({
left: direction === 'next' ? cardWidth + 16 : -(cardWidth + 16),
behavior: 'smooth',
})
}
return (
<div className="relative">
<button
onClick={() => scrollBy('prev')}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-lg rounded-full w-10 h-10"
>
←
</button>
<div
ref={trackRef}
className="flex gap-4 overflow-x-scroll px-12"
style={{ scrollSnapType: 'x mandatory', scrollbarWidth: 'none' }}
>
{cards.map((card, i) => (
<div
key={i}
style={{ scrollSnapAlign: 'start', flexShrink: 0, width: 300 }}
>
{card}
</div>
))}
</div>
<button
onClick={() => scrollBy('next')}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-lg rounded-full w-10 h-10"
>
→
</button>
</div>
)
}
Типичные сроки
Базовый секционный скролл с навигационными точками — 4–6 часов. Горизонтальная карусель + навигация + индикаторы + адаптив — 1 рабочий день.







