Skip to content
← All posts

3D on the web that degrades gracefully

3 min read#threejs#performance#nextjs

How this site decides between a full WebGL scene, a lighter one, and a static SVG, and why three.js never loads on devices that can't use it.

A WebGL background is a nice first impression on a fast laptop and a bad one on a five-year-old phone. The particle field behind this site, which changes from a halo to a wave to a torus knot to a galaxy as you scroll, has three tiers, and each visitor gets the one their device can run.

The three tiers

  • High: the full scene, antialiased, device pixel ratio up to 2, 6,000 particles.
  • Low: the same scene with antialiasing off, pixel ratio capped at 1.25 and 2,200 particles.
  • Static: an SVG drawing of the hero composition over a CSS gradient, for browsers without WebGL. No three.js download.

That last point matters most. The scene is loaded with next/dynamic and ssr: false, so the roughly 240 KB (gzipped) of three.js, React Three Fiber and Drei is only requested once a device has been assigned a 3D tier:

const WorldScene = dynamic(() => import("./WorldScene"), { ssr: false });

The server always renders the SVG, and it stays visible until the canvas has drawn its first frame, so the hero is never blank while three.js loads.

Choosing a tier

The check runs once, after the page mounts:

  1. WebGL. If the browser can't create a WebGL context at all, the visitor gets the static tier. That's the only up-front reason to skip 3D.
  2. Software rendering. If a context only works without failIfMajorPerformanceCaveat, the browser is rendering on the CPU, so the scene starts on the low tier and lets the performance monitor decide.
  3. Hardware hints. navigator.deviceMemory and hardwareConcurrency split devices into low and high. A small touch screen is capped at low.

Visitors with prefers-reduced-motion still get the 3D, but a calm version of it: the orb ripples slowly, the particles drift, and nothing follows the pointer.

Adjusting while it runs

Heuristics are guesses, so the scene keeps checking once it's running. Drei's <PerformanceMonitor> watches the frame rate, lowers the pixel ratio when it drops and raises it again when there's headroom. If it keeps flip-flopping, or the browser loses the WebGL context, the site switches to the SVG version.

Two more details:

  • There's a single fixed canvas for the whole site, not one per section, so there's only ever one WebGL context and one render loop.
  • A small 3D on/off toggle lets visitors override the automatic choice, and the setting is remembered.

The result is the same composition everywhere, with rendering matched to the device.