Реализация Sticky Header/Footer на сайте
Sticky-элементы — тривиальны через position: sticky, но правильная реализация учитывает прокрутку вниз/вверх, производительность анимаций, safe-area на мобильных и конфликт с anchor-ссылками.
Базовый sticky header
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
/* Оптимизация: отдельный слой для compositing */
will-change: transform;
/* Тень только когда прилип */
transition: box-shadow 0.2s ease;
}
.site-header--scrolled {
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
}
const header = document.querySelector('.site-header')!
let lastScrollY = 0
function onScroll() {
const currentScrollY = window.scrollY
// Добавляем тень после первых пикселей
header.classList.toggle('site-header--scrolled', currentScrollY > 10)
lastScrollY = currentScrollY
}
// Throttle через rAF — не чаще 60fps
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
onScroll()
ticking = false
})
ticking = true
}
}, { passive: true })
Hide-on-scroll: прячем при прокрутке вниз
Популярный паттерн: хедер исчезает при скролле вниз и появляется при скролле вверх:
const THRESHOLD = 100 // не прятать при коротком скролле вниз
const HIDE_AFTER = 50 // минимальный скролл для скрытия
let scrollStart = 0
let hidden = false
function onScroll() {
const y = window.scrollY
const diff = y - lastScrollY
if (diff > 0) {
// Скролл вниз
if (!hidden && y > THRESHOLD && y - scrollStart > HIDE_AFTER) {
header.classList.add('site-header--hidden')
hidden = true
}
} else {
// Скролл вверх
if (hidden) {
header.classList.remove('site-header--hidden')
hidden = false
scrollStart = y
}
}
lastScrollY = y
}
.site-header {
transition: transform 0.3s ease;
}
.site-header--hidden {
transform: translateY(-100%);
}
transform вместо top: -100px — анимация на GPU, без reflow.
React: хук для sticky-логики
import { useState, useEffect, useRef } from 'react'
interface StickyHeaderState {
isScrolled: boolean
isVisible: boolean
scrollY: number
}
function useStickyHeader(hideOnScrollDown = true): StickyHeaderState {
const [state, setState] = useState<StickyHeaderState>({
isScrolled: false,
isVisible: true,
scrollY: 0,
})
const lastScrollY = useRef(0)
const scrollStart = useRef(0)
useEffect(() => {
let ticking = false
const handler = () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
const y = window.scrollY
const diff = y - lastScrollY.current
setState(prev => {
let isVisible = prev.isVisible
if (hideOnScrollDown) {
if (diff > 0 && y > 100 && y - scrollStart.current > 50) {
isVisible = false
} else if (diff < 0) {
if (!isVisible) scrollStart.current = y
isVisible = true
}
}
return {
isScrolled: y > 10,
isVisible,
scrollY: y,
}
})
lastScrollY.current = y
ticking = false
})
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [hideOnScrollDown])
return state
}
export function SiteHeader() {
const { isScrolled, isVisible } = useStickyHeader(true)
return (
<header
className={[
'site-header',
isScrolled ? 'site-header--scrolled' : '',
!isVisible ? 'site-header--hidden' : '',
].filter(Boolean).join(' ')}
>
{/* ... */}
</header>
)
}
Sticky footer / bottom navigation
/* Bottom navigation для мобильных */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
background: #fff;
border-top: 1px solid #e2e8f0;
/* iOS safe area — отступ от системной навигации */
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: max(env(safe-area-inset-bottom), 8px);
}
<!-- Viewport meta для safe-area -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
/* Компенсация на странице — контент не должен быть под navbar */
.page-content {
padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
Sticky sidebar
/* Sticky sidebar — остаётся при скролле контента рядом */
.sidebar {
position: sticky;
top: calc(var(--header-height) + 24px); /* учитываем sticky header */
max-height: calc(100vh - var(--header-height) - 48px);
overflow-y: auto;
overscroll-behavior: contain;
}
Конфликт с anchor-ссылками
position: sticky header перекрывает якорные ссылки. Решение через CSS scroll-margin:
/* Добавляем отступ сверху для всех элементов с id (потенциальных якорей) */
[id] {
scroll-margin-top: calc(var(--header-height) + 16px);
}
Или JavaScript-версия для устаревших браузеров:
document.querySelectorAll('a[href^="#"]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault()
const targetId = (link as HTMLAnchorElement).hash.slice(1)
const target = document.getElementById(targetId)
if (!target) return
const headerHeight = (document.querySelector('.site-header') as HTMLElement)?.offsetHeight ?? 0
const top = target.getBoundingClientRect().top + window.scrollY - headerHeight - 16
window.scrollTo({ top, behavior: 'smooth' })
})
})
Индикатор прогресса в header
const progressBar = document.createElement('div')
progressBar.style.cssText = `
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(to right, #6366f1, #8b5cf6);
z-index: 9999;
transition: width 0.1s linear;
transform-origin: left;
`
document.body.appendChild(progressBar)
window.addEventListener('scroll', () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight
const progress = scrollable > 0 ? (window.scrollY / scrollable) * 100 : 0
progressBar.style.width = `${progress}%`
}, { passive: true })
Сроки
Sticky header с тенью при скролле — 1–2 часа. С hide-on-scroll анимацией и React-хуком — полдня. С bottom nav, safe-area, sticky sidebar и anchor-фиксом — 1 день.







