Реалізація плавного скролу на веб-сайті
Плавний скролл — це не scroll-behavior: smooth в CSS. Це управління швидкістю та характером прокрутки: інерція, easing, привязка до секцій, синхронізація з анімаціями. Нативний CSS-варіант дає одне фіксоване значення плавності без можливості настройки.
CSS-підхід: коли його достатньо
Для простих випадків — якорні посилання всередині сторінки, повернення вгору — нативного CSS хватає:
html {
scroll-behavior: smooth;
}
/* Відступ при наявності фіксованого хедера */
[id] {
scroll-margin-top: 80px;
}
// Якорна навігація
document.querySelectorAll('a[href^="#"]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault()
const target = document.querySelector(link.getAttribute('href')!)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
})
Обмеження: не можна настроїти швидкість, немає подій початку/кінця прокрутки, не однаково підтримується у всіх браузерах, не працює з кастомним scroll-контейнером.
JavaScript-реалізація без бібліотек
Коли потрібен контроль: кастомний easing, callbacks, програмна прокрутка з різною швидкістю.
type EasingFn = (t: number) => number
const easings: Record<string, EasingFn> = {
linear: (t) => t,
easeInOutCubic: (t) => t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2,
easeOutQuart: (t) => 1 - (1 - t) ** 4,
easeInOutExpo: (t) => t === 0 ? 0 : t === 1 ? 1
: t < 0.5 ? 2 ** (20 * t - 10) / 2
: (2 - 2 ** (-20 * t + 10)) / 2,
}
function scrollTo(
target: number | HTMLElement,
options: {
duration?: number
easing?: keyof typeof easings
offset?: number
onComplete?: () => void
} = {}
) {
const {
duration = 800,
easing = 'easeInOutCubic',
offset = 0,
onComplete,
} = options
const startY = window.scrollY
const endY = typeof target === 'number'
? target
: target.getBoundingClientRect().top + window.scrollY + offset
const distance = endY - startY
const easeFn = easings[easing]
let startTime: number | null = null
function step(timestamp: number) {
if (!startTime) startTime = timestamp
const elapsed = timestamp - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = easeFn(progress)
window.scrollTo(0, startY + distance * eased)
if (progress < 1) {
requestAnimationFrame(step)
} else {
onComplete?.()
}
}
requestAnimationFrame(step)
}
Scroll Snap
CSS Scroll Snap привязує прокрутку до секцій. Працює нативно та продуктивно, не вимагає JS.
.scroll-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory; /* або proximity */
}
.scroll-section {
height: 100vh;
scroll-snap-align: start;
scroll-snap-stop: always; /* заборона "перельоту" через секцію */
}
/* Горизонтальний snap для слайдера */
.slider {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.slide {
flex: 0 0 100%;
scroll-snap-align: center;
}
Відслідковування позиції скролу
Реактивне відслідковування для анімацій при скролі. Throttle через requestAnimationFrame замість setTimeout:
class ScrollTracker {
private scrollY = 0
private ticking = false
private listeners: Set<(y: number, direction: 'up' | 'down') => void> = new Set()
private lastY = 0
constructor() {
window.addEventListener('scroll', this.onScroll, { passive: true })
}
private onScroll = () => {
this.scrollY = window.scrollY
if (!this.ticking) {
requestAnimationFrame(() => {
const direction = this.scrollY > this.lastY ? 'down' : 'up'
this.listeners.forEach(fn => fn(this.scrollY, direction))
this.lastY = this.scrollY
this.ticking = false
})
this.ticking = true
}
}
subscribe(fn: (y: number, direction: 'up' | 'down') => void) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
destroy() {
window.removeEventListener('scroll', this.onScroll)
}
}
// Використання
const tracker = new ScrollTracker()
tracker.subscribe((y, dir) => {
// Приховати хедер при скролі вниз, показати при скролі вгору
header.classList.toggle('header--hidden', dir === 'down' && y > 100)
})
Intersection Observer для анімацій при появленні
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('in-view')
// Відписатися якщо анімація одноразова
observer.unobserve(entry.target)
}
})
},
{
threshold: 0.15, // 15% елемента має бути видно
rootMargin: '0px 0px -50px 0px', // відступ знизу
}
)
document.querySelectorAll('[data-animate]').forEach((el) => {
observer.observe(el)
})
[data-animate] {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
[data-animate].in-view {
opacity: 1;
transform: translateY(0);
}
/* Staggered delay для груп */
[data-animate]:nth-child(1) { transition-delay: 0ms; }
[data-animate]:nth-child(2) { transition-delay: 100ms; }
[data-animate]:nth-child(3) { transition-delay: 200ms; }
Доступність
prefers-reduced-motion — системний флаг, який потрібно поважати. Користувачі з вестибулярними розстройствами вимикають анімацію в системних налаштуваннях.
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
[data-animate] {
transition: none;
opacity: 1;
transform: none;
}
}
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// Якщо анімація не потрібна — миттєва прокрутка
function safeScrollTo(target: HTMLElement) {
if (prefersReduced) {
target.scrollIntoView()
} else {
scrollTo(target, { duration: 800 })
}
}
Терміни
Якорна навігація + Intersection Observer анімації — половина дня. Кастомний smooth scroll з easing, scroll-tracker для хедера та staggered анімаціями — 1 день.







