Реалізація анімації завантаження сайту (Preloader)
Preloader — екран, що показується поки сторінка завантажується. Його завдання: приховати частково завантажений вміст, дати змогу програти вводну анімацію, створити відчуття намиреного початку. Поганий preloader дратує. Хороший — органічно переходить на перший екран.
Коли preloader виправданий
Preloader потрібен якщо: сайт завантажує важкі WebGL-сцени або відео перед показом, дизайн вимагає синхронної анімації тексту/логотипу при старті, або перший екран без контенту буде виглядати некоректно. Для звичайних інформаційних сайтів preloader — лишня затримка.
Базова структура
Preloader вставляється першим у <body>, працює на чистому CSS без JS-залежностей для начального рендеру:
<body>
<div id="preloader" class="preloader">
<div class="preloader__content">
<div class="preloader__logo">
<svg>...</svg>
</div>
<div class="preloader__counter">
<span id="preloader-count">0</span>
<span>%</span>
</div>
</div>
<div class="preloader__overlay"></div>
</div>
<main id="app">
<!-- Вміст прихований до закінчення preloader -->
</main>
</body>
.preloader {
position: fixed;
inset: 0;
z-index: 9999;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Вміст прихований під час завантаження */
body.is-loading #app {
visibility: hidden;
}
Відслідковування прогресу завантаження
class Preloader {
private counter: HTMLElement
private preloader: HTMLElement
private currentCount = 0
private targetCount = 0
private resources: string[]
private loaded = 0
private rafId: number | null = null
constructor(resources: string[] = []) {
this.counter = document.getElementById('preloader-count')!
this.preloader = document.getElementById('preloader')!
this.resources = resources
document.body.classList.add('is-loading')
}
async load(): Promise<void> {
if (this.resources.length === 0) {
// Якщо ресурсів нема — імітуємо прогрес
await this.fakeProgress()
} else {
await this.loadResources()
}
await this.hide()
document.body.classList.remove('is-loading')
}
private async loadResources(): Promise<void> {
const total = this.resources.length
const promises = this.resources.map((url) =>
this.loadAsset(url).then(() => {
this.loaded++
this.targetCount = Math.round((this.loaded / total) * 100)
})
)
// Анімуємо лічильник паралельно з завантаженням
this.animateCounter()
await Promise.all(promises)
this.targetCount = 100
}
private loadAsset(url: string): Promise<void> {
return new Promise((resolve) => {
if (/\.(jpg|png|webp|svg|gif)$/i.test(url)) {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => resolve() // не блокувати на помилці
img.src = url
} else if (/\.(mp4|webm)$/i.test(url)) {
const video = document.createElement('video')
video.oncanplaythrough = () => resolve()
video.onerror = () => resolve()
video.src = url
video.load()
} else {
fetch(url)
.then(() => resolve())
.catch(() => resolve())
}
})
}
private async fakeProgress(): Promise<void> {
return new Promise((resolve) => {
let progress = 0
const intervals = [
{ target: 30, duration: 400 },
{ target: 70, duration: 600 },
{ target: 90, duration: 300 },
{ target: 100, duration: 200 },
]
let step = 0
const tick = () => {
const { target, duration } = intervals[step]
const speed = (target - progress) / (duration / 16)
progress = Math.min(progress + speed, target)
this.targetCount = Math.round(progress)
if (progress >= target) {
step++
if (step >= intervals.length) {
resolve()
return
}
}
requestAnimationFrame(tick)
}
this.animateCounter()
tick()
})
}
private animateCounter() {
const tick = () => {
if (this.currentCount < this.targetCount) {
this.currentCount = Math.min(
this.currentCount + Math.ceil((this.targetCount - this.currentCount) * 0.1),
this.targetCount
)
this.counter.textContent = String(this.currentCount)
}
if (this.currentCount < 100) {
this.rafId = requestAnimationFrame(tick)
}
}
this.rafId = requestAnimationFrame(tick)
}
private async hide(): Promise<void> {
// Чекаємо пока лічильник дійде до 100
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (this.currentCount >= 100) {
clearInterval(check)
resolve()
}
}, 50)
})
return new Promise((resolve) => {
// Анімація зникнення через GSAP
import('gsap').then(({ default: gsap }) => {
const tl = gsap.timeline({ onComplete: () => {
this.preloader.style.display = 'none'
resolve()
}})
tl.to('.preloader__counter', { opacity: 0, duration: 0.3 })
.to('.preloader', {
clipPath: 'inset(0 0 100% 0)',
duration: 0.8,
ease: 'power3.inOut',
})
.from('#app', { opacity: 0, duration: 0.4 }, '-=0.2')
})
})
}
}
// Ініціалізація
const preloader = new Preloader([
'/images/hero-bg.webp',
'/images/about-photo.webp',
])
preloader.load().then(() => {
// Сторінка готова — запускаємо основні анімації
initHeroAnimation()
})
Анімація логотипу
Типовий варіант: SVG логотип з path анімацією через stroke-dashoffset.
.preloader__logo path {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: draw-logo 1.5s ease forwards;
}
@keyframes draw-logo {
to { stroke-dashoffset: 0; }
}
// Після закінчення draw-анімації — fill
gsap.to('.preloader__logo path', {
fill: '#ffffff',
stroke: 'transparent',
duration: 0.4,
delay: 1.6,
})
Збереження стану — показувати один раз
Якщо preloader має показуватися тільки при першому візиті в сесію:
const PRELOADER_KEY = 'preloader_shown'
function shouldShowPreloader(): boolean {
if (sessionStorage.getItem(PRELOADER_KEY)) return false
sessionStorage.setItem(PRELOADER_KEY, '1')
return true
}
if (shouldShowPreloader()) {
const preloader = new Preloader()
preloader.load()
} else {
document.getElementById('preloader')?.remove()
document.body.classList.remove('is-loading')
}
Терміни
Простий preloader з progress-баром та fade-out — 4–6 годин. Анімація логотипу, відслідковування реальних ресурсів, плавний перехід до першого екрану — 1–2 дні.







