Read contentBoxSize[0] or borderBoxSize[0] — the logical, writing-mode-aware sizes — matching the box you observe, use devicePixelContentBoxSize[0] for canvas backing stores, and treat contentRect as a legacy convenience that is always the content box in physical width/height.
Problem / Scenario Context
A design-system card adjusts its layout using entry.contentRect.width. It works everywhere until the product ships a Japanese vertical-text edition: in writing-mode: vertical-rl the card's "width" breakpoint now responds to its height, and layouts flip at the wrong sizes. Separately, a data-grid component sizes columns from contentRect and ends up 2px too narrow per column because the design uses a 1px border that contentRect excludes. And a chart component draws a blurry canvas because it multiplies contentRect.width by devicePixelRatio and rounds differently from how the browser snaps the canvas to device pixels.
All three read a size that does not describe what they need. The ResizeObserver Mechanics & Triggers topic covers when entries arrive; this page covers which number to take from them.
Mechanics Explanation
A ResizeObserverEntry carries four sizes:
contentRect— aDOMRectReadOnlyof the content box, in physicalwidthandheight, withx/yset to the padding offsets. It exists for backward compatibility with the first version of the API.contentBoxSize— an array ofResizeObserverSizeobjects, each withinlineSizeandblockSize: the content box in logical axes. In horizontal writing modes, inline is width; in vertical modes, inline is height.borderBoxSize— the same shape for the border box: content plus padding plus border.devicePixelContentBoxSize— the content box in device pixels, snapped exactly as the browser will paint it. The only reliable input for a crisp canvas.
The sizes are arrays because the spec anticipates elements split into multiple fragments (multi-column layout, paginated media); today every browser returns exactly one entry, so [0] is correct.
Separately, observe(target, { box }) chooses which box change triggers delivery. Observing content-box (the default) delivers when the content box changes; border-box when the border box changes; device-pixel-content-box when the device-pixel size changes, which includes zoom and DPR changes that leave CSS sizes untouched. All four size fields are populated regardless of which box triggered the entry.
Comparison Table: Which Size for Which Job
| Job | Read | Observe box |
Why |
|---|---|---|---|
| Responsive component breakpoints | contentBoxSize[0].inlineSize |
content-box |
space available for content, writing-mode aware |
| Fitting into a parent slot | borderBoxSize[0] |
border-box |
the footprint the element occupies |
| Canvas backing store | devicePixelContentBoxSize[0] |
device-pixel-content-box |
exact device pixels; updates on zoom |
| Text reflow decisions | contentBoxSize[0] |
content-box |
padding does not hold text |
| Legacy code, horizontal-only | contentRect.width |
content-box |
compatible, but not writing-mode aware |
| Position within parent | none — sizes only | — | use getBoundingClientRect |
Minimal Reproducible Example
const card = document.querySelector<HTMLElement>('.card')!; // padding 16px, border 1px
new ResizeObserver(([e]) => {
console.table({
'contentRect.width': e.contentRect.width,
'contentBox inline': e.contentBoxSize[0].inlineSize,
'borderBox inline': e.borderBoxSize[0].inlineSize,
'device px inline': e.devicePixelContentBoxSize?.[0].inlineSize,
});
}).observe(card);
// Now switch the card to vertical text:
card.style.writingMode = 'vertical-rl';
In horizontal mode, contentRect.width equals the content inline size and the border box is 34px larger. After switching to vertical text, contentRect.width still reports the physical width while inlineSize now reports the physical height — the dimension text actually runs along.
Production-Safe Solution
export interface ElementSize {
inline: number; // content box, logical
block: number;
borderInline: number; // footprint, logical
borderBlock: number;
devicePxInline?: number;
devicePxBlock?: number;
}
export function readSize(e: ResizeObserverEntry): ElementSize {
// Old engines shipped contentRect only; newer ones may return a single object, not an array.
const first = <T,>(v: T | readonly T[] | undefined): T | undefined =>
Array.isArray(v) ? v[0] : (v as T | undefined);
const content = first(e.contentBoxSize);
const border = first(e.borderBoxSize);
const dp = first(e.devicePixelContentBoxSize);
return {
inline: content?.inlineSize ?? e.contentRect.width,
block: content?.blockSize ?? e.contentRect.height,
borderInline: border?.inlineSize ?? (e.target as HTMLElement).offsetWidth,
borderBlock: border?.blockSize ?? (e.target as HTMLElement).offsetHeight,
devicePxInline: dp?.inlineSize,
devicePxBlock: dp?.blockSize,
};
}
// Breakpoints on available content space, writing-mode aware:
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const { inline } = readSize(e);
(e.target as HTMLElement).dataset.size = inline < 360 ? 's' : inline < 720 ? 'm' : 'l';
}
});
ro.observe(card, { box: 'content-box' });
The fallbacks cover engines that predate the box-size arrays (and early Firefox, which exposed a single object instead of an array). Reading offsetWidth in the fallback is safe inside the callback because layout is clean at that point, but on current browsers the branch never runs.
For canvas, observe the device-pixel box so zoom changes deliver an entry even when the CSS size does not change:
const canvasRO = new ResizeObserver(([e]) => {
const s = readSize(e);
const canvas = e.target as HTMLCanvasElement;
canvas.width = s.devicePxInline ?? Math.round(s.inline * devicePixelRatio);
canvas.height = s.devicePxBlock ?? Math.round(s.block * devicePixelRatio);
redraw(canvas);
});
try { canvasRO.observe(canvas, { box: 'device-pixel-content-box' }); }
catch { canvasRO.observe(canvas); } // engines without the box option
declare const canvas: HTMLCanvasElement;
declare function redraw(c: HTMLCanvasElement): void;
The details of pixel snapping are in using devicePixelContentBox for crisp canvas.
How the Observed Box Changes Delivery
Choosing the observed box is not only about which number you read; it decides which changes wake you up. Observing content-box means a padding change delivers nothing (the content box did not change) — handy when padding is animated and you only care about content space. Observing border-box delivers for padding and border changes too, which is right when the element's footprint matters to a parent layout.
That choice also interacts with the resize loop. A callback that toggles a class adding padding is harmless under content-box observation and can loop under border-box, because the write changes the observed size. When in doubt, observe the box whose size your callback reads, and write only properties outside it. The loop mechanics are covered in ResizeObserver and the update-the-rendering steps.
Verification Steps
- Log all four sizes for a padded, bordered element and confirm the differences match its CSS.
- Switch to
writing-mode: vertical-rland confirm breakpoints still respond to the text direction. - Zoom the browser with a canvas observed via
device-pixel-content-boxand confirm an entry arrives and the canvas stays crisp. - Animate padding on an element observed with
content-boxand confirm no entries are delivered. - Run in an older browser to exercise the fallback path if you support one.
Common Mistakes to Avoid
- Using
contentRectin writing-mode-aware components. It is always physical. - Reading content size when the footprint matters. Borders and padding are excluded.
- Multiplying CSS size by
devicePixelRatiofor canvas. The browser's snapping can differ by a pixel; use the device-pixel box. - Forgetting the array index.
contentBoxSize.inlineSizeisundefined; it iscontentBoxSize[0].inlineSize.
FAQ
Why are contentBoxSize and borderBoxSize arrays?
To support elements split into multiple fragments, such as content flowing across columns. No browser fragments observed elements today, so the arrays always contain exactly one item.
Is contentRect deprecated?
It is kept for compatibility and is not going away, but the specification describes it as legacy. New code should use the box-size arrays, which are writing-mode aware.
Do I get borderBoxSize if I observe content-box?
Yes. All size fields are filled in on every entry. The observed box only decides which changes cause an entry to be delivered.
Is devicePixelContentBoxSize supported everywhere?
It is supported in Chromium and Firefox. Safari has lagged, so feature-detect it and fall back to rounding the CSS size multiplied by devicePixelRatio.
Which box should a container-query-like component observe?
The content box. CSS container queries size against the container's content box, so observing content-box and reading contentBoxSize[0].inlineSize keeps script breakpoints consistent with any container queries used alongside them.
Why does contentRect have non-zero x and y?
They are the padding offsets: the content box's position relative to the padding box's top-left corner. They say nothing about the element's position on the page.
Related
- ResizeObserver Firing on Page Load Explained — the first entry
- ResizeObserver Box Options Browser Support — what each engine supports
- Resizing a Canvas with ResizeObserver Without Blurring — device pixels in practice
↑ Back to ResizeObserver Mechanics & Triggers