useElementHeight
A client-safe React hook that measures an element's height with ResizeObserver so a container can animate between content heights.
measurementheight-animation
Animate to real content height
useElementHeight measures one rendered element and gives a container a stable pixel height to animate toward. It is the right fit for accordions, collapsibles, step flows, drawers, and panels where the width is fixed but the content height changes.
Code
"use client";
import { useCallback, useRef, useState } from "react";
/**
* Tracks an element's rendered height so a container can animate between content
* heights — accordions, collapsibles, step flows, expanding cards, anything that
* grows or shrinks to fit its content.
*
* Why this exists: you still can't reliably CSS-transition `height: auto` across
* browsers (the native `interpolate-size` / `calc-size()` approach is promising
* but not yet universally supported). The portable pattern is "measure, then
* animate to a pixel value" — which is exactly what this hook gives you.
*
* @example
* const [ref, height] = useElementHeight<HTMLDivElement>();
* return (
* <motion.div animate={{ height: height ?? "auto" }} className="overflow-hidden">
* <div ref={ref}>{content}</div>
* </motion.div>
* );
*
* Notes for animators:
* - Uses a *callback ref*, so it re-measures correctly even when the measured
* element is conditionally rendered or swapped out — the common case for
* collapsibles. (A `useRef` + `useEffect([])` hook would silently miss that.)
* - Returns a `measure` callback for state changes that must synchronize their
* height target before paint, such as a step transition with layout motion.
* - Reads the *border-box* height (padding + border included), because that's the
* box the parent actually has to grow to.
* - `threshold` ignores sub-pixel ResizeObserver noise that would otherwise
* re-trigger the animation every frame and read as jitter.
*/
export function useElementHeight<T extends HTMLElement>(threshold = 0.5) {
const [height, setHeight] = useState<number | null>(null);
const elementRef = useRef<T | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const updateHeight = useCallback(
(nextHeight: number) => {
setHeight((currentHeight) => {
if (currentHeight === null) return nextHeight;
return Math.abs(currentHeight - nextHeight) > threshold
? nextHeight
: currentHeight;
});
},
[threshold]
);
const measure = useCallback(() => {
const element = elementRef.current;
if (!element) return;
updateHeight(element.getBoundingClientRect().height);
}, [updateHeight]);
const ref = useCallback(
(element: T | null) => {
// Tear down the previous observer whenever the element changes or unmounts.
observerRef.current?.disconnect();
elementRef.current = element;
if (!element) return;
updateHeight(element.getBoundingClientRect().height);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const borderBoxSize = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: entry.borderBoxSize;
updateHeight(
borderBoxSize?.blockSize ??
entry.target.getBoundingClientRect().height
);
});
observer.observe(element);
observerRef.current = observer;
},
[updateHeight]
);
return [ref, height, measure] as const;
}