Реалізація 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 робочий день.







