Недавно на проекте с 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 под ваш проект — свяжитесь с нами. Мы оценим задачу и предложим оптимальное решение.







