Agents (llms.txt)
Octocat

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

PropDefaultTypeDescription
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) => voidCalled 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 measure
  • size ({ width: number, height: number } | undefined): The element’s size, undefined until 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.

Measuring…
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.

Saved.
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.

Drag the corner. Rendered 1 time.
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