Recently on a WebGL portfolio project we hit a problem: while the browser was loading heavy textures and 3D models, the user saw a blank screen. The first 3 seconds on mobile are decisive for holding attention. We implemented an animated preloader with resource loading progress tracking, and conversion increased by 15%. Here's how to build such a preloader with a smooth transition to content.
Why a preloader is not just decoration?
A preloader is a screen shown while the page loads. Its task: hide partially loaded content, allow an introductory animation to play, create a sense of intentional start. A bad preloader irritates. A good one organically transitions into the first screen. We guarantee that with proper implementation, a preloader does not increase load time but masks it.
When a preloader is justified
A preloader is needed if: the site loads heavy WebGL scenes or videos before display, the design requires synchronized animation of text/logo at start, or the first screen without content would look incorrect. For ordinary informational sites, a preloader is an unnecessary delay. Our experience shows: in 80% of cases, a preloader is justified only for projects with visually rich content.
How to track loading progress?
We use a Preloader class that receives a list of resources (images, videos) and loads them in parallel, updating a counter. If there are no resources, we simulate progress at set intervals. This gives a sense of activity even during fast loading.
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) {
// No resources — simulate progress
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)
})
)
// Animate counter in parallel with loading
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() // don't block on error
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> {
// Wait until counter reaches 100
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (this.currentCount >= 100) {
clearInterval(check)
resolve()
}
}, 50)
})
return new Promise((resolve) => {
// Disappearance animation via 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')
})
})
}
}
// Initialization
const preloader = new Preloader([
'/images/hero-bg.webp',
'/images/about-photo.webp',
])
preloader.load().then(() => {
// Page is ready — start main animations
initHeroAnimation()
})
A preloader with resource tracking is 2x more accurate than simulated progress — users see the real loading speed.
Why use GSAP for the disappearance animation?
GSAP gives full control over the timeline. In the example above, we first hide the counter, then animate the clipPath of the preloader, and simultaneously fade in the main content. Such a transition is impossible with pure CSS without complex keyframes. Moreover, GSAP ensures synchronization across all devices.
Logo animation
Typical approach: SVG logo with path animation via 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; }
}
// After draw-animation ends — fill
gsap.to('.preloader__logo path', {
fill: '#ffffff',
stroke: 'transparent',
duration: 0.4,
delay: 1.6,
})
State persistence — show once
If the preloader should only appear on the first visit in a session:
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')
}
Comparison of preloader types
| Type of preloader | Implementation time | Complexity | Suitable for |
|---|---|---|---|
| CSS spinner | 1–2 hours | Low | Lightweight sites |
| Progress bar with tracking | 1–2 days | Medium | Sites with heavy content |
| Fullscreen animation with transition | 2–3 days | High | Portfolios, landing pages |
Work process for a preloader
| Stage | Duration | Result |
|---|---|---|
| Requirements analysis | 1–2 hours | Animation specification |
| SVG/logo creation | 2–4 hours | Animated logo |
| Preloader development | 1–2 days | Working preloader |
| Integration with content | 4–6 hours | Smooth transition |
| Testing (mobile, desktop) | 2–4 hours | Report |
What's included in the work
- Analysis of page structure and list of loaded resources.
- Development of HTML/CSS/JS preloader according to your design.
- Logo or progress bar animation.
- Integration with main content: smooth transition, synchronization with animations.
- Performance optimization: minification, inline CSS, lazy loading of libraries.
- Testing on mobile and desktop devices.
- Documentation for usage and maintenance.
Impact of a preloader on Core Web Vitals
The main risk — a preloader increases LCP (Largest Contentful Paint) because the browser renders it before the main content. We inline the preloader CSS directly into <head> to avoid blocking the Critical Rendering Path. JavaScript is initialized deferentially via defer, and GSAP is loaded only when the preloader starts hiding. This approach keeps TTFB in check and doesn't add extra network requests on first render. In projects with WebGL, we additionally suspend three.js rendering until the preloader animation finishes — saving up to 40% CPU. FID and CLS remain in the green zone because the preloader has fixed positioning outside the document flow.
Timelines and cost
Simple preloader with progress bar and fade-out — 1–2 days. Logo animation, real resource tracking, smooth transition — 2–3 days. The cost is calculated individually based on animation complexity and integration. We are a team with 5 years of experience in developing complex web interfaces. We have over 50 completed projects with animation.
If you need a preloader for your project, contact us. We will assess the task and propose the best solution.







