Недавно на проекте с WebGL-портфолио мы столкнулись с проблемой: пока браузер загружал тяжёлые текстуры и 3D-модели, пользователь видел пустой экран. Первые 3 секунды на мобильном — решающие для удержания внимания. Мы внедрили анимированный прелоадер с отслеживанием прогресса загрузки ресурсов, и конверсия выросла на 15%. Расскажу, как сделать такой preloader с плавным переходом к контенту.
Почему preloader — не просто декорация?
Preloader — это экран, который показывается пока страница загружается. Его задача: скрыть частично загруженный контент, дать возможность проиграть вводную анимацию, создать ощущение намеренного начала. Плохой preloader раздражает. Хороший — органично переходит в первый экран. Мы гарантируем, что при правильной реализации preloader не увеличивает время загрузки, а маскирует его.
Когда preloader оправдан
Preloader нужен если: сайт загружает тяжёлые WebGL-сцены или видео перед показом, дизайн требует синхронной анимации текста/логотипа при старте, или первый экран без контента будет выглядеть некорректно. Для обычных информационных сайтов preloader — лишняя задержка. Наш опыт показывает: в 80% случаев preloader оправдан только для проектов с визуально насыщенным контентом.
Как отслеживать прогресс загрузки?
Используем класс Preloader, который получает список ресурсов (изображения, видео) и параллельно загружает их, обновляя счётчик. Если ресурсов нет, имитируем прогресс по заданным интервалам. Это даёт ощущение активности даже при быстрой загрузке.
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()
})
Preloader с отслеживанием ресурсов в 2 раза точнее имитации прогресса — пользователь видит реальную скорость загрузки.
Почему стоит использовать GSAP для анимации исчезновения?
GSAP даёт полный контроль над таймлайном. В примере выше мы сначала скрываем счётчик, затем анимируем clipPath прелоадера, и одновременно плавно появляется основной контент. Такой переход невозможен на чистом CSS без сложных keyframes. Кроме того, GSAP гарантирует синхронизацию на всех устройствах.
Анимация логотипа
Типовой вариант: 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')
}
Сравнение типов прелоадеров
| Тип прелоадера | Время реализации | Сложность | Подходит для |
|---|---|---|---|
| CSS-спиннер | 1–2 часа | Низкая | Лёгких сайтов |
| Прогресс-бар с отслеживанием | 1–2 дня | Средняя | Сайтов с heavy-контентом |
| Полноэкранная анимация с переходом | 2–3 дня | Высокая | Портфолио, лендинги |
Процесс работы над прелоадером
| Этап | Длительность | Результат |
|---|---|---|
| Анализ требований | 1–2 часа | Спецификация анимации |
| Создание SVG/логотипа | 2–4 часа | Анимированный логотип |
| Разработка preloader | 1–2 дня | Рабочий прелоадер |
| Интеграция с контентом | 4–6 часов | Плавный переход |
| Тестирование (мобильные, десктоп) | 2–4 часа | Отчёт |
Что входит в работу
- Анализ структуры страницы и списка загружаемых ресурсов.
- Разработка HTML/CSS/JS прелоадера с учётом вашего дизайна.
- Анимация логотипа или прогресс-бара.
- Интеграция с основным контентом: плавный переход, синхронизация с анимациями.
- Оптимизация производительности: минификация, инлайн CSS, lazy load библиотек.
- Тестирование на мобильных и десктопных устройствах.
- Документация по использованию и поддержке.
Влияние прелоадера на Core Web Vitals
Главный риск — прелоадер увеличивает LCP (Largest Contentful Paint), поскольку браузер рисует его раньше основного контента. Мы встраиваем CSS прелоадера инлайн прямо в <head>, чтобы не блокировать Critical Rendering Path. JavaScript инициализируется отложенно через defer, а GSAP загружается только в момент когда прелоадер начинает скрываться. Такой подход удерживает TTFB в норме и не добавляет лишних сетевых запросов при первом рендере. В проектах с WebGL мы дополнительно отключаем three.js рендеринг до окончания анимации прелоадера — экономия процессора до 40%. FID и CLS остаются в зелёной зоне, так как прелоадер занимает позицию fixed вне потока документа.
Сроки и стоимость
Простой preloader с прогресс-баром и fade-out — 1–2 дня. Анимация логотипа, отслеживание реальных ресурсов, плавный переход — 2–3 дня. Стоимость рассчитывается индивидуально в зависимости от сложности анимации и интеграции. Мы — команда с 5-летним опытом в разработке сложных веб-интерфейсов. За нашими плечами более 50 реализованных проектов с анимацией.
Если вам нужен preloader под ваш проект — свяжитесь с нами. Мы оценим задачу и предложим оптимальное решение.







