Every time you've built a chart, a virtualised list, or a component that reacts to its own size, you've probably wrestled with this: how do I know when an element's dimensions change?
The naive approaches are painful:
window.resizeโ fires on window size changes, not your element's- Polling
offsetWidth/Heightโ janky, runs on the main thread, bad for performance - MutationObserver on parent โ fires on any DOM change, expensive, misses CSS changes
ResizeObserver solves this directly. It fires when an element's content or padding size changes โ and only then. No polling, no noise.
The Core API
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log('Element resized:', entry.target);
console.log('New size:', entry.contentRect);
}
});
observer.observe(document.querySelector('.my-element'));entries is an array because you can observe multiple elements with one observer. Each entry has:
interface ResizeObserverEntry {
target: Element; // which element changed
contentRect: DOMRectReadOnly; // the new content box dimensions
borderBoxSize: ResizeObserverSize; // border box (older spec)
contentBoxSize: ResizeObserverSize; // content box (newer spec)
devicePixelContentBoxSize: ResizeObserverSize; // device pixels
}contentRect is the most commonly used:
interface DOMRectReadOnly {
x: number; // left edge position relative to layout root
y: number; // top edge position relative to layout root
width: number; // content width
height: number; // content height
top: number; // distance from layout root top to top edge
right: number;
bottom: number;
left: number;
}A Practical Example: Responsive Typography
A common use case โ adjust font size based on the container width:
function setupFluidTypography(element) {
new ResizeObserver(([entry]) => {
const { width } = entry.contentRect;
// Scale from 16px at 320px wide to 24px at 640px wide
const fontSize = Math.max(16, Math.min(24, (width / 640) * 24 + 16));
element.style.fontSize = `${fontSize}px`;
}).observe(element);
}This is cleaner than window.resize because it only reacts to the element you care about.
The Aspect Ratio Lock Pattern
If you've ever wanted an element to maintain a specific aspect ratio as it resizes โ think video embeds, image cards, or hero sections โ ResizeObserver makes it clean:
function lockAspectRatio(element, ratio = 16 / 9) {
new ResizeObserver(([entry]) => {
const { width } = entry.contentRect;
element.style.height = `${width / ratio}px`;
}).observe(element);
}
// Usage
lockAspectRatio(document.querySelector('.video-embed'));This is better than the old padding-bottom hack because:
- It works with any
heightproperty (flex children, grid items) - It fires on actual layout changes, not just initial render
- Multiple elements can have different ratios independently
Chart Libraries: The Real Use Case
ResizeObserver is how chart libraries like Chart.js and Recharts handle responsive resizing. Here's a simplified version of what they do:
class ResponsiveChart {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.observer = new ResizeObserver(this.handleResize.bind(this));
this.observer.observe(canvas.parentElement);
}
handleResize(entries) {
const [{ contentRect }] = entries;
const { width, height } = contentRect;
// Set canvas buffer size to match display size
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
// Scale the drawing context so our chart code stays in CSS pixels
this.ctx.scale(dpr, dpr);
this.draw();
}
draw() {
// Chart drawing logic using this.canvas.width/height
}
}The key insight: ResizeObserver watches the parent, not the canvas itself, because CSS can resize any ancestor and affect the canvas indirectly.
Virtualised Lists: Size-Aware Rendering
Virtual list libraries (react-virtual, react-window) use ResizeObserver to detect when the container size changes and recalculate how many items to render:
class VirtualScroller {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.rowHeight = 50; // estimated, can be dynamic
this.observer = new ResizeObserver(this.onResize.bind(this));
this.observer.observe(container);
}
onResize([{ contentRect }]) {
const visibleCount = Math.ceil(contentRect.height / this.rowHeight);
const scrollTop = this.container.scrollTop;
this.startIndex = Math.floor(scrollTop / this.rowHeight);
this.endIndex = Math.min(
this.startIndex + visibleCount + 2, // +2 for buffer
this.items.length
);
this.render();
}
render() {
// Only render items in [startIndex, endIndex)
// Position them absolutely using transform: translateY()
}
}The devicePixelContentBoxSize โ For Canvas and WebGL
When drawing on a <canvas> or WebGL context, you need to account for device pixel ratio to avoid blurry rendering. The devicePixelContentBoxSize array gives you dimensions in actual device pixels:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// For a HiDPI/Retina display, this gives physical pixels
const [{ inlineSize, blockSize }] = entry.devicePixelContentBoxSize;
canvas.width = inlineSize;
canvas.height = blockSize;
// Draw in physical pixels โ context is already scaled
drawScene();
}
});
observer.observe(canvas, { box: 'device-pixel-content-box' });Note: devicePixelContentBoxSize is newer and may not be supported in all browsers โ always polyfill with contentRect fallback:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
let width, height;
if (entry.devicePixelContentBoxSize) {
// Newer browsers: physical pixels
[{ inlineSize: width, blockSize: height }] = entry.devicePixelContentBoxSize;
} else {
// Fallback: CSS pixels
width = entry.contentRect.width;
height = entry.contentRect.height;
}
// ...
}
});The box Option โ Which Box to Watch
ResizeObserver can watch different box models:
// Watch content box (default) โ the inside of padding box
observer.observe(element, { box: 'content-box' });
// Watch border box โ the outer edge of the border
observer.observe(element, { box: 'border-box' });
// Watch device pixel content box (for canvas/WebGL)
observer.observe(element, { box: 'device-pixel-content-box' });In practice, content-box (the default) is what you want 90% of the time.
Disconnect and Unobserve
Don't forget to clean up:
class MyComponent {
constructor() {
this.observer = new ResizeObserver(this.onResize.bind(this));
this.observer.observe(this.el);
}
destroy() {
// Stop observing everything
this.observer.disconnect();
}
}If you only want to stop observing a specific element (while keeping the observer active for others):
observer.unobserve(specificElement);Common Pitfalls
ResizeObserver is asynchronous
ResizeObserver callbacks fire after layout, but not synchronously โ they're batched. This is good for performance (you won't get mid-layout snapshots) but means you can't use it to prevent a layout.
Content box vs border box confusion
The contentRect reports the content box (inside padding), not the border box. If you have a thick border and expect contentRect.width to include it โ it won't.
.my-element {
width: 200px;
padding: 20px;
border: 10px solid red;
}contentRect.width will be 200 โ the border and padding are outside it.
The element must be in the DOM
ResizeObserver only fires when the observed element is in the layout tree. If you observe a hidden element (display: none or visibility: hidden), it won't fire.
Comparison with Alternatives
| Approach | What it detects | Performance | Notes |
|---|---|---|---|
ResizeObserver | Element's own content box | โ Excellent | Use this |
window.resize | Browser viewport only | โ ๏ธ Triggers on anything | Use for full-page layouts |
Polling offsetWidth | Any element | โ Bad | Causes layout thrashing |
MutationObserver | DOM children changes | โ ๏ธ Misses CSS-only changes | Use for DOM structure, not size |
IntersectionObserver | Element visibility | โ Good | Different use case โ visibility, not size |
Summary
ResizeObserverfires when an element's content box changes- It's more precise than
window.resizeand more efficient than polling - Use
contentRectfor CSS pixel dimensions,devicePixelContentBoxSizefor HiDPI canvases - Clean up with
disconnect()orunobserve()when done - Libraries like Chart.js, react-virtual, and Vue's
v-resizedirective all use it under the hood
If you've been polling window.innerWidth or watching a parent MutationObserver to detect layout changes โ this is the tool you're looking for.