CSS size is not render size
A canvas has two sizes. CSS decides how large it appears on the page. The drawing buffer decides how many pixels WebGL renders. If these values drift apart, the scene can look blurry, stretched, or expensive. A good resize function reads the displayed size, updates the renderer drawing buffer, and then updates the camera projection.
Avoid setting canvas width and height in CSS alone and hoping Three.js will infer the rest. The renderer must know the real buffer size. At the same time, avoid letting `setSize` rewrite your CSS every frame. Pass `false` as the third argument so layout remains controlled by CSS.
Clamp pixel ratio
High-DPI screens can request two or three times as many pixels as the CSS size suggests. That can make a small phone canvas surprisingly expensive. Clamp renderer pixel ratio with `Math.min(window.devicePixelRatio, 2)` or even a lower value for heavy scenes. The difference is often invisible to users but very visible to battery and frame rate.
For static product viewers, sharpness matters. For animated shader backgrounds, smoothness may matter more. Make the pixel ratio choice per scene instead of copying one value across every project.
Resize when the container changes
Window resize is a start, but modern layouts can change canvas size without a full window resize. Sidebars open, tabs change, browser UI collapses, and CSS grid tracks shift. ResizeObserver is a better fit for tool panels and responsive hero scenes because it watches the actual container.
The render loop can call a cheap resize check before drawing, but do not trigger expensive work if width and height did not change. Compare the current renderer size to the target size, then update only when needed.
Test the awkward sizes
Responsive scenes often fail between common breakpoints. Test a narrow phone, a tablet width, and a very wide desktop. Check whether text overlays still have contrast, whether the object remains framed, and whether UI controls overlap the canvas. A technically responsive canvas can still produce a poor composition.
For AdSense-friendly pages, this testing also matters because layout quality affects perceived trust. A guide page with a broken hero canvas on mobile feels unfinished even if the article text is strong.
Resize a live canvas and inspect the result
The examples gallery uses container dimensions for its canvases and keeps the surrounding page readable at desktop and mobile widths.
- Canvas follows its container
- Camera aspect updates
- Page remains usable without the preview
Compare CSS pixels before resizing the buffer
Assigning the window size on every frame wastes work and ignores embedded containers.
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;const width = container.clientWidth;
const height = container.clientHeight;
if (canvas.width !== width * pixelRatio || canvas.height !== height * pixelRatio) {
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}Viewport check matrix
These are the two automated viewport sizes used by the local smoke checks.
| Viewport | Expected condition |
|---|---|
| 390 x 844 | No horizontal document overflow |
| 1366 x 900 | Canvas at least 120 x 120 CSS pixels |
| Pixel ratio | Clamped to at most 2 |
| Reduced motion | Static first frame remains visible |
Reproduce a blurry or stretched canvas
Separate layout size, drawing-buffer size, and camera projection while debugging.
- Record the canvas CSS width and height.
- Record canvas.width and canvas.height after pixel-ratio scaling.
- Confirm camera.aspect uses the same CSS dimensions.
- Resize the container without reloading and repeat the measurements.
Resize helper
function resizeRenderer(renderer, camera, container) {
const width = Math.max(1, container.clientWidth);
const height = Math.max(1, container.clientHeight);
const current = renderer.getSize(new THREE.Vector2());
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
if (current.x !== width || current.y !== height) {
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
}
Sources and further reading
Update record
- : Rewritten around a reproducible demo, explicit test record, code comparison, and known platform limits.