diff --git a/src/music/features/visualizer/Renderer.ts b/src/music/features/visualizer/Renderer.ts
index f5648c9..3597b59 100644
--- a/src/music/features/visualizer/Renderer.ts
+++ b/src/music/features/visualizer/Renderer.ts
@@ -19,6 +19,7 @@ class Renderer {
 
     private lastFrameTime: number;
     private dTime: number;
+    private nextAnimationFrame?: number;
 
     private rotation: number;
 
@@ -53,13 +54,63 @@ class Renderer {
         this.buffers = {};
     }
 
-    resize() {
-        this.canvas.width = this.canvas.parentElement!.clientWidth;
-        this.canvas.height = this.canvas.parentElement!.clientHeight;
+    resizeAndDraw(
+        gl: WebGL2RenderingContext,
+        shader: Shader,
+        observerData: ResizeObserverEntry | null
+    ) {
+        if (this.canvas.parentElement === null) {
+            throw new Error("renderer has been removed from dom");
+        }
+
+        if (this.nextAnimationFrame) {
+            cancelAnimationFrame(this.nextAnimationFrame);
+        }
+
+        // Note: For this to work, it's *incredibly important* for the
+        // canvas to be overflowable by its parent, and its parent to
+        // have `overflow: hidden` set. If using a flexbox, this means
+        // that the canvas has to be `position: absolute`.
+        let width: number;
+        let height: number;
+
+        if (observerData !== null && observerData.devicePixelContentBoxSize) {
+            width = observerData.devicePixelContentBoxSize[0].inlineSize;
+            height = observerData.devicePixelContentBoxSize[0].blockSize;
+        } else {
+            // Fallback; the above API is even newer than
+            // ResizeObserver, and by setting the observerData to null
+            // we can manually resize at least once without going
+            // through the API.
+            if (this.canvas.parentElement === null) {
+                throw new Error("canvas parent disappeared");
+            }
+            // Note: This *requires* `box-sizing: border-box`
+            ({ width, height } =
+                this.canvas.parentElement.getBoundingClientRect());
+        }
+
+        this.canvas.width = width;
+        this.canvas.height = height;
+
+        gl.viewport(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
+        this.updateProjection(gl, shader);
+
+        // ResizeObserver will call when we should draw, so do our own
+        // time calculation and draw the scene.
+        this.updateTime(performance.now());
+        this.drawScene(gl, shader);
+    }
+
+    updateTime(time: number) {
+        this.dTime = time - this.lastFrameTime;
+        this.lastFrameTime = time;
     }
 
     initializeScene() {
-        this.resize();
+        if (this.canvas.parentElement === null) {
+            throw new Error("canvas was not added to page");
+        }
 
         const gl = this.canvas.getContext("webgl2");
         if (gl === null) {
@@ -78,12 +129,30 @@ class Renderer {
             .build();
 
         this.initGL(gl, shader);
-        this.initMatrices(gl, shader);
+        this.updateProjection(gl, shader);
         this.initBuffers(gl);
-        this.drawScene(gl, shader);
+
+        try {
+            const observer = new ResizeObserver((elements) => {
+                // We only observe one element
+                const element = elements[0];
+                this.resizeAndDraw(gl, shader, element);
+            });
+            observer.observe(this.canvas.parentElement);
+        } catch (error) {
+            // If the browser does not support ResizeObserver, we
+            // simply don't resize. Resizing is hard enough, just use
+            // a modern browser.
+            if (error instanceof ReferenceError) {
+                console.warn(
+                    "Browser does not support `ResizeObserver`. Canvas resizing will be disabled."
+                );
+            } else throw error;
+        }
+        this.resizeAndDraw(gl, shader, null);
     }
 
-    initMatrices(gl: WebGLRenderingContext, shader: Shader) {
+    updateProjection(gl: WebGLRenderingContext, shader: Shader) {
         const projectionMatrix = mat4.create();
         mat4.perspective(
             projectionMatrix,
@@ -264,9 +333,8 @@ class Renderer {
             this.overlay.innerText = `${dTime}ms (${cpuTime}ms / ${gpuTime}ms)`;
         }
 
-        requestAnimationFrame((time) => {
-            this.dTime = time - this.lastFrameTime;
-            this.lastFrameTime = time;
+        this.nextAnimationFrame = requestAnimationFrame((time) => {
+            this.updateTime(time);
 
             this.drawScene(gl, shader);
         });
diff --git a/src/music/features/visualizer/Visualizer.tsx b/src/music/features/visualizer/Visualizer.tsx
index c45228e..8081d6b 100644
--- a/src/music/features/visualizer/Visualizer.tsx
+++ b/src/music/features/visualizer/Visualizer.tsx
@@ -101,7 +101,13 @@ function Visualizer({
                 ref={visualizer}
                 className="is-flex-grow-1 is-clipped is-relative"
             >
-                <canvas style={{ display: "block" }}></canvas>
+                <canvas
+                    style={{
+                        display: "block",
+                        position: "absolute",
+                        boxSizing: "border-box",
+                    }}
+                ></canvas>
                 <span
                     className="is-bottom-left"
                     style={{ display: "relative" }}