Implementing Website Loading Animation (Preloader)
Preloader is a screen shown while page loads. Its task: hide partially loaded content, allow intro animation playback, create sense of intentional start. Bad preloader annoys. Good one smoothly transitions to first screen.
When Preloader is Justified
Preloader needed if: site loads heavy WebGL scenes or video before showing, design requires synchronized animation of text/logo at start, or first screen without content looks incorrect. For regular info sites, preloader is extra delay.
Basic Structure
Preloader inserted first in <body>, works on clean CSS without JS dependencies for initial render:
<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">
<!-- Content hidden until preloader ends -->
</main>
</body>
.preloader {
position: fixed;
inset: 0;
z-index: 9999;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Content hidden during loading */
body.is-loading #app {
visibility: hidden;
}
Tracking Load Progress
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) {
// If 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 alongside 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 for counter to reach 100
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (this.currentCount >= 100) {
clearInterval(check)
resolve()
}
}, 50)
})
return new Promise((resolve) => {
// Disappear 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 ready—run main animations
initHeroAnimation()
})
Logo Animation
Typical variant: 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,
})
Preserving State — Show Once
If preloader should show once per 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')
}
Timeline
Simple preloader with progress bar and fade-out — 4–6 hours. Logo animation, real resource tracking, smooth first screen transition — 1–2 days.







