My portfolio opens with a typewriter scene built in Three.js. It looks great the first time. The tenth time, it's a delay.
This post is about the rules that keep a nice-to-have animation from turning into an obstacle, with the actual mechanics so you can copy them. The short version: flair should serve the first impression and then get out of the way.
The temptation
A 3D intro is fun to build. It shows off the skills the site exists to demonstrate. And for a first-time visitor it sets a tone: a typewriter clacking out the masthead of a newspaper fits the theme.
But think about what it costs the visitor:
- A wait before they can see anything useful.
- Weight: Three.js and a scene mean extra JavaScript to download and run.
- Repetition: someone who opens the site twice, or navigates away and back, sees it again.
A feature that's delightful once and irritating ten times needs rules.
Rule 1: show it once per session
I store a flag in sessionStorage. Once the intro has played in a tab, it doesn't play again in that tab.
const KEY = 'press-intro-seen'; function shouldPlayIntro(): boolean { try { if (new URLSearchParams(location.search).has('noload')) return false; return !sessionStorage.getItem(KEY); } catch { return false; // storage blocked: skip the intro, don't break the page } } function markIntroSeen() { try { sessionStorage.setItem(KEY, '1'); } catch { /* ignore */ } } Two choices worth noting:
- I used
sessionStorage, notlocalStorage. A new session gets the intro again, which keeps the delight for returning visitors without being relentless within a visit. - The
try/catchmatters. Some browsers or privacy modes block storage; in that case the safe behaviour is to skip the intro rather than throw and break the page.
Rule 2: let people skip it
Any click or key press jumps past the intro.
function enableSkip(onSkip: () => void) { const handler = () => { window.removeEventListener('pointerdown', handler); window.removeEventListener('keydown', handler); onSkip(); }; window.addEventListener('pointerdown', handler, { once: true }); window.addEventListener('keydown', handler, { once: true }); } This is the single most important rule. An animation people can't skip is an animation for the author, not the visitor. Anyone in a hurry, anyone on a slow device, anyone who's already seen it gets an instant exit.
Rule 3: provide a URL escape hatch
?noload skips the intro entirely. It's useful for three reasons:
- Development. I don't want to watch the typewriter every time I refresh while styling a section.
- Testing. Automated browser checks can skip straight to the content.
- Sharing. If I send someone a link to a specific part of the site, I can send the version that doesn't make them wait.
Rule 4: lazy-load everything below the hero
The intro shouldn't block the rest of the page from being ready. Every home section below the hero is loaded lazily (React.lazy), so the heavy 3D code and the lower sections don't compete for bandwidth on first load.
const Projects = React.lazy(() => import('./sections/Projects')); const Blog = React.lazy(() => import('./sections/Blog')); // ...inside the page <Suspense fallback={null}> <Projects /> <Blog /> </Suspense> A short accessibility and performance checklist
Before shipping any intro or motion-heavy hero, check:
-
prefers-reduced-motion. Respect it. Offer a static version for people who've asked for less motion. - Keyboard users. Can they skip it without a mouse? (A key press does it.)
- Screen readers. Is the real content in the DOM and reachable, not trapped behind a canvas?
- Low-end devices. Does it hold a reasonable frame rate, or does it make the page feel broken?
- Slow connections. Measure the added weight. A big scene can cost more than it gives.
The principle
Flair should serve the visitor's first impression and then get out of the way.
If a feature can't be skipped, it's for me, not for them. Everything else on this list is just ways of putting that sentence into code.
Honest downside
3D adds weight, and no amount of lazy loading fully hides that. If your audience is on slow connections or older devices, measure before you add it, and be ready to cut it. A beautiful intro that makes your site feel slow has made the wrong trade.
A minimal Three.js scene (about 40 lines)
If you've never used Three.js, here's the smallest useful scene: a rotating cube. Everything else is variations on this.
import * as THREE from 'three'; const canvas = document.querySelector('canvas')!; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap cost on hi-dpi screens const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100); camera.position.z = 4; const cube = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial() ); scene.add(cube); function resize() { const { clientWidth: w, clientHeight: h } = canvas; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } window.addEventListener('resize', resize); resize(); renderer.setAnimationLoop(() => { cube.rotation.x += 0.01; cube.rotation.y += 0.015; renderer.render(scene, camera); }); Two details worth copying into any scene: cap the pixel ratio (rendering at 3× on a phone is rarely worth the battery) and handle resize.
Respecting reduced motion
Some visitors ask their device for less motion. Honour it:
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduceMotion) { showStaticMasthead(); // plain, instant version of the hero } else { playIntro(); } This is more than politeness. For people with vestibular disorders, large animated scenes can cause real discomfort.
Clean up after yourself
A scene that's no longer visible should stop costing CPU and GPU:
function stopIntro() { renderer.setAnimationLoop(null); // stop the render loop scene.traverse(obj => { if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose(); const m = (obj as THREE.Mesh).material; if (m) (Array.isArray(m) ? m : [m]).forEach(x => x.dispose()); }); renderer.dispose(); canvas.remove(); } Forgetting this is a classic cause of pages that get slower the longer you stay on them.
A performance budget
Decide limits before building, so "just one more effect" has something to bump against.
| Budget item | Example target |
|---|---|
| Added JavaScript for the intro | As small as you can; measure it |
| Time until content is usable | Content must not wait for the intro |
| Frame rate on a mid-range phone | Smooth enough that it doesn't feel broken |
| Memory after intro ends | Back to baseline (dispose everything) |
I'm deliberately not giving numeric thresholds, because they depend on your audience. Pick your own and measure them with the browser's performance tools.
Questions to ask before adding a hero animation
- Does it help the visitor understand something, or only impress?
- Can it be skipped instantly?
- What happens on a slow phone with a bad connection?
- What happens with JavaScript disabled or storage blocked?
- Will I still like it on the fiftieth visit?
If three of the five answers are bad, simplify.
A middle path
If you love the effect but not the cost, make it optional and on demand: a "play intro" link in the footer, or show it only on the very first visit. Delight you choose beats delight you're forced through.
What flashy feature have you added to a site and later regretted? And did you cut it, or did you make it optional?

