useElementSize
A hook that observes an element's size with ResizeObserver
Source Code
import { useEffect, useRef, useState } from "react";
// a `type`, not an `interface`: it has to be assignable to `style` (`CSSProperties` has an index
// signature for custom properties, which interfaces don't satisfy)
type ElementSize = {
width: number;
height: number;
};
type ElementSizeCallback = (size: ElementSize, entry: ResizeObserverEntry) => void;
interface UseElementSizeOptions {
box?: ResizeObserverBoxOptions;
}
const readSize = (entry: ResizeObserverEntry, box: ResizeObserverBoxOptions): ElementSize => {
const sizes = {
"border-box": entry.borderBoxSize,
"content-box": entry.contentBoxSize,
"device-pixel-content-box": entry.devicePixelContentBoxSize,
}[box];
const size = sizes?.[0];
// Older Safari has no box sizes: `contentRect` is the content box, and the layout size
// (`offset*`, rounded, unaffected by transforms) stands in for the border box
if (!size) {
const { target } = entry;
if (box === "border-box" && target instanceof HTMLElement) {
return { width: target.offsetWidth, height: target.offsetHeight };
}
return { width: entry.contentRect.width, height: entry.contentRect.height };
}
// box sizes are logical: in a vertical writing mode the inline axis is the vertical one
const vertical = !getComputedStyle(entry.target).writingMode.startsWith("horizontal");
return vertical
? { width: size.blockSize, height: size.inlineSize }
: { width: size.inlineSize, height: size.blockSize };
};
export const useElementSize = <T extends HTMLElement>(
{ box = "border-box" }: UseElementSizeOptions = {},
callback?: ElementSizeCallback,
) => {
const ref = useRef<T>(null);
const [size, setSize] = useState<ElementSize | undefined>(undefined);
const callbackRef = useRef<ElementSizeCallback | undefined>(callback);
callbackRef.current = callback;
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = readSize(entry, box);
if (callbackRef.current) {
callbackRef.current(next, entry);
} else {
// same size, same object: no re-render
setSize((prev) =>
prev?.width === next.width && prev.height === next.height ? prev : next,
);
}
});
observer.observe(element, { box });
return () => observer.disconnect();
}, [box]);
return [ref, size] as const;
};
export type { ElementSize };Features
- Live size: Re-renders with the element’s width and height whenever it resizes, and only when the values change
- Box choice: Measure the border box (default), the content box, or device pixels
- Callback mode: React to resizes without re-rendering, for writing styles or CSS variables directly
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
options | { box: "border-box" } | { box?: "border-box" | "content-box" | "device-pixel-content-box" } | Which box to measure. Passed to ResizeObserver.observe. |
callback | - | (size: { width: number, height: number }, entry: ResizeObserverEntry) => void | Called on every resize. When provided, the returned size is never updated. |
The hook returns a tuple containing:
ref(RefObject<T>): A ref to attach to the element you want to measuresize({ width: number, height: number } | undefined): The element’s size,undefineduntil the first measurement (always on the server)
When a callback is provided, size stays undefined and the component doesn’t re-render on
resize. Use it when the size feeds the DOM directly, such as a CSS variable or an inline style. It
works like the callback in useIntersectionObserver.
The element must be rendered when the component mounts: the ref is read once, in an effect.
Safari doesn’t support device-pixel-content-box; observe throws there, so check
support before passing it.
Examples
Basic
Drag the corner to resize.
import { useElementSize } from "../use-element-size";
function UseElementSizeBasicPreview() {
const [ref, size] = useElementSize<HTMLDivElement>();
return (
<div
ref={ref}
className="flex h-32 w-64 min-w-32 max-w-full resize items-center justify-center overflow-auto rounded-md border border-border bg-foreground/5 text-sm tabular-nums"
>
{size ? `${Math.round(size.width)} × ${Math.round(size.height)}` : "Measuring…"}
</div>
);
}
export default UseElementSizeBasicPreview;
const [ref, size] = useElementSize<HTMLDivElement>();
return <div ref={ref}>{size && `${size.width} × ${size.height}`}</div>;
Animating to content size
CSS can’t transition width or height to or from auto. Measure the content instead, and give
its container that size: when the content changes, the container eases into its new size. Since
size is undefined until measured, the container starts at its natural size. The
Toaster uses this when a toast is updated.
import { useState } from "react";
import { Button } from "@/components/button";
import { useElementSize } from "../use-element-size";
const SHORT = "Saved.";
const LONG =
"Saved. Your changes are live, and anyone with the link will see the new version next time they open it.";
function UseElementSizeContentPreview() {
const [long, setLong] = useState(false);
// measure the content, then give the box that size, so it eases instead of snapping
const [ref, size] = useElementSize<HTMLDivElement>();
return (
<div className="flex flex-col items-start gap-4">
<Button size="sm" variant="outline" onClick={() => setLong((v) => !v)}>
{long ? "Shorter" : "Longer"}
</Button>
<div
style={size}
className="box-content overflow-hidden rounded-md border border-border bg-background shadow-sm motion-safe:transition-[width,height] motion-safe:duration-500 motion-safe:ease-spring"
>
<div ref={ref} className="w-max max-w-72 p-3 text-sm">
{long ? LONG : SHORT}
</div>
</div>
</div>
);
}
export default UseElementSizeContentPreview;
const [ref, size] = useElementSize<HTMLDivElement>();
return (
// `box-content`: the measured size is the content's, any border sits outside it
<div style={size} className="box-content overflow-hidden transition-[width,height]">
<div ref={ref} className="w-max max-w-72">
{content}
</div>
</div>
);
Writing a CSS variable
With a callback, the size goes straight to the DOM and the component renders once. Here another
element matches the panel’s width through --panel-width.
import { useRef } from "react";
import { useElementSize } from "../use-element-size";
function UseElementSizeCallbackPreview() {
const rootRef = useRef<HTMLDivElement>(null);
const renders = useRef(0);
renders.current += 1;
// writes a CSS variable on every resize, without re-rendering
const [ref] = useElementSize<HTMLDivElement>({}, ({ width }) => {
rootRef.current?.style.setProperty("--panel-width", `${width}px`);
});
return (
<div ref={rootRef} className="flex flex-col gap-2">
<div
ref={ref}
className="h-24 w-64 min-w-24 max-w-full resize-x overflow-auto rounded-md border border-border bg-foreground/5 p-3 text-sm"
>
Drag the corner. Rendered {renders.current} time{renders.current === 1 ? "" : "s"}.
</div>
<div className="h-1.5 w-(--panel-width) rounded-full bg-foreground/40" />
</div>
);
}
export default UseElementSizeCallbackPreview;
const rootRef = useRef<HTMLDivElement>(null);
const [ref] = useElementSize<HTMLDivElement>({}, ({ width }) => {
rootRef.current?.style.setProperty("--panel-width", `${width}px`);
});
Fitting items to the space
For layouts that depend on how many things fit, which container queries can’t count: show as
many avatars as fit and collapse the rest into “+N”. content-box leaves the padding out of the
measurement.
import { useElementSize } from "../use-element-size";
const PEOPLE = ["AM", "BK", "CL", "DS", "EN", "FO", "GP", "HR", "IT", "JV", "KW", "LZ"];
const AVATAR = 32;
const GAP = 4;
function UseElementSizeFitPreview() {
const [ref, size] = useElementSize<HTMLDivElement>({ box: "content-box" });
// as many avatars as fit, keeping one slot for "+N" when some are left over
const slots = size ? Math.max(1, Math.floor((size.width + GAP) / (AVATAR + GAP))) : 0;
const shown = PEOPLE.length > slots ? Math.max(0, slots - 1) : PEOPLE.length;
const hidden = PEOPLE.length - shown;
return (
<div
ref={ref}
className="flex w-80 min-w-16 max-w-full resize-x gap-1 overflow-hidden rounded-md border border-border p-2"
>
{PEOPLE.slice(0, shown).map((initials) => (
<span
key={initials}
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-xs"
>
{initials}
</span>
))}
{size && hidden > 0 && (
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-background text-xs">
+{hidden}
</span>
)}
</div>
);
}
export default UseElementSizeFitPreview;
const [ref, size] = useElementSize<HTMLDivElement>({ box: "content-box" });
const slots = size ? Math.floor((size.width + GAP) / (AVATAR + GAP)) : 0;
Previous
useDetectDevice
Next
useElementTransition