Three.js From Zero · Article s14-03
Loading & Progress UIs
Loading & Progress UIs is Article s14-03 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.
Season 14 · Article 03 · Setup & Tooling
Perceived performance > actual performance. A 5-second load with a progress bar feels faster than a 3-second load with a blank screen. Three.js gives you LoadingManager; you wrap it in a UI that builds trust during the wait.
LoadingManager — the central hub
const manager = new THREE.LoadingManager();
manager.onStart = (url, loaded, total) => console.log('starting', url);
manager.onProgress = (url, loaded, total) => {
const pct = (loaded / total) * 100;
updateBar(pct);
};
manager.onLoad = () => hideSplash();
manager.onError = (url) => console.error('failed', url);
// Pass the manager to every loader
const gltfLoader = new GLTFLoader(manager);
const textureLoader = new THREE.TextureLoader(manager);
const ktx2Loader = new KTX2Loader(manager);
All loaders sharing one manager means progress is aggregated. loaded / total is the asset count, not bytes — for byte-accurate progress, you have to layer something else (see below).
The splash screen pattern
<div id="splash">
<div id="logo">...</div>
<div id="bar"><div id="bar-fill"></div></div>
<div id="status">Loading…</div>
</div>
function updateBar(pct) {
document.getElementById('bar-fill').style.width = pct + '%';
document.getElementById('status').textContent = `Loading… ${pct.toFixed(0)}%`;
}
function hideSplash() {
const splash = document.getElementById('splash');
splash.style.opacity = '0';
setTimeout(() => splash.remove(), 500);
}
The splash is HTML, not Three.js. It loads instantly (no JS dependency, no model load), shows progress, fades out when ready. Native CSS transitions = smooth.
Byte-accurate progress (the upgrade)
LoadingManager's loaded/total counts files, not bytes. A 5MB model and a 5KB texture count the same. For accurate progress, intercept the fetch:
function progressFetch(url, onProgress) {
return fetch(url).then(async (res) => {
const total = +res.headers.get('content-length');
const reader = res.body.getReader();
let loaded = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onProgress(loaded / total);
}
return new Blob(chunks);
});
}
Wraps fetch to report bytes. Use it for your biggest asset (the glb model), then weight that against the smaller assets in the overall progress calculation.
Hide the bar entirely if you can
Best UX for fast loads: no bar at all. If your assets load under 800ms, show a small spinner only after a 400ms delay:
let spinnerTimer = setTimeout(() => showSpinner(), 400);
manager.onLoad = () => { clearTimeout(spinnerTimer); hideSpinner(); };
If the load finishes before 400ms, the spinner never shows — feels instant. If it takes longer, the spinner appears, signaling "I'm working." Best of both worlds.
Skeleton 3D — placeholder geometry
Sometimes the load is so heavy you need to give them something to look at:
// On manager.onStart, add wireframe placeholders matching the final scene
function addSkeletons() {
scene.add(new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
new THREE.MeshBasicMaterial({ wireframe: true, color: 0x444444 }),
));
}
manager.onStart = addSkeletons;
manager.onLoad = () => clearSkeletons();
Common first-time pitfalls
Content-Length on .glb/.hdr files.fetch manually outside the manager — those don't register.scene.traverse texture readiness, or just add a small artificial delay before hiding.Exercises
- Build the splash. HTML + CSS only. Use it on a project with 3MB of assets. Profile perceived load time vs no splash.
- 400ms delay pattern. Implement the "no spinner if fast" UX. Test with throttled and full-speed network.
- Weighted progress. Three files: 5MB glb, 200KB texture, 50KB shader. Weight progress by byte ratio so the bar advances linearly with actual download bytes.
UP NEXT
S14-04 — Three.js + Web Components → Wrap a scene as <three-scene>.