Skip to content

Animating Container Bounds

Have you ever tried to animate a container's width or height with Motion and run into this?

badge 13
badge 13

The one on the left just snaps to its new size. The text animates in fine, but the container itself jumps. That's because width and height are not animatable. The browser doesn't know how to interpolate between a fixed value and "whatever the content needs."

The one on the right is way smoother and feels better. Same content change, but the width animates to fit. So how do we achieve this?

Building a useMeasure Hook

Before we get into the animation part, we need a way to track an element's dimensions. The browser has a native API for this called ResizeObserver.1 It watches an element and fires a callback whenever its size changes. We can wrap this in a small hook.

import { useCallback, useEffect, useState } from "react";

function useMeasure() {
  const [element, setElement] = useState(null);
  const [bounds, setBounds] = useState({ width: 0, height: 0 });

  const ref = useCallback((node) => {
    setElement(node);
  }, []);

  useEffect(() => {
    if (!element) return;

    const observer = new ResizeObserver(([entry]) => {
      setBounds({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    });

    observer.observe(element);
    return () => observer.disconnect();
  }, [element]);

  return [ref, bounds];
}

The hook returns a ref callback and a bounds object. Attach the ref to any element and bounds will always reflect its current width and height. When the content inside that element changes and causes a resize, the observer fires, state updates, and your component re-renders with the new values.

There are libraries like react-use-measure that do this too, but as you can see it's only a few lines of code. No dependency needed.2

Integrating with Motion

Now for the animation. The core idea is two divs. An outer div that you animate, and an inner div that you measure. Attach the ref from useMeasure to the inner div, the hook gives you bounds.width and bounds.height which update whenever the content changes, pass those values to Motion's animate prop on the outer div.

import { motion } from "motion/react";

function AnimatedContainer({ children }) {
  const [ref, bounds] = useMeasure();

  return (
    <motion.div animate={{ height: bounds.height }}>
      <div ref={ref}>{children}</div>
    </motion.div>
  );
}

Animating Width

Buttons that change their label are a common use case. Loading states, confirmation messages, multi-step forms. Without animated bounds the button jumps between widths. With this pattern, it slides.

Code Playground
import { MotionConfig, type MotionProps, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import styles from "./styles.module.css";

const animation: MotionProps = {
  initial: { opacity: 0, filter: "blur(8px)", scale: 0.95 },
  animate: { opacity: 1, filter: "blur(0px)", scale: 1 },
  exit: { opacity: 0, filter: "blur(8px)", scale: 0.95 },
  transition: {
    duration: 0.4,
    ease: [0.19, 1, 0.22, 1],
    delay: 0.05,
    opacity: {
      duration: 0.6,
      ease: "easeInOut",
    },
  },
};

function useMeasure<T extends HTMLElement = HTMLElement>(): [
  (node: T | null) => void,
  { width: number; height: number },
] {
  const [element, setElement] = useState<T | null>(null);
  const [bounds, setBounds] = useState({ width: 0, height: 0 });

  const ref = useCallback((node: T | null) => {
    setElement(node);
  }, []);

  useEffect(() => {
    if (!element) return;

    const observer = new ResizeObserver(([entry]) => {
      setBounds({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    });

    observer.observe(element);
    return () => observer.disconnect();
  }, [element]);

  return [ref, bounds];
}

const labels = ["Lorem Ipsum", "Ex Amet", "Aliqua Velit"];

export default function App() {
  const [index, setIndex] = useState(0);
  const [ref, bounds] = useMeasure();

  function handleClick() {
    setIndex((prev) => (prev + 1) % labels.length);
  }

  return (
    <div className={styles.container}>
      <MotionConfig
        transition={{
          duration: 0.4,
          ease: [0.19, 1, 0.22, 1],
          delay: 0.05,
        }}
      >
        <motion.button
          animate={{
            width: bounds.width > 0 ? bounds.width : "auto",
          }}
          onClick={handleClick}
          className={styles.button}
        >
          <div ref={ref} className={styles.wrapper}>
            <motion.span
              {...animation}
              key={labels[index]}
              className={styles.label}
            >
              {labels[index]}
            </motion.span>
          </div>
        </motion.button>
      </MotionConfig>
    </div>
  );
}

The trick here is the ref goes on the inner wrapper, not the button itself. The button's width is controlled by Motion. The wrapper's width is controlled by its content. When the content changes, the measured width updates, and the button smoothly resizes to fit.

One thing to note is that I'm checking bounds.width > 0 before animating. This avoids an animation from 0 on the initial render. You want the first frame to just show the button at its natural size, not animate in from nothing.

Animating Height

Height is where this pattern really shines. Expandable sections, accordions, FAQs, details panels. Anywhere content reveals or hides itself.

The same pattern applies. Measure the inner content, animate the outer container.

Code Playground
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import styles from "./styles.module.css";

function useMeasure<T extends HTMLElement = HTMLElement>(): [
  (node: T | null) => void,
  { width: number; height: number },
] {
  const [element, setElement] = useState<T | null>(null);
  const [bounds, setBounds] = useState({ width: 0, height: 0 });

  const ref = useCallback((node: T | null) => {
    setElement(node);
  }, []);

  useEffect(() => {
    if (!element) return;

    const observer = new ResizeObserver(([entry]) => {
      setBounds({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    });

    observer.observe(element);
    return () => observer.disconnect();
  }, [element]);

  return [ref, bounds];
}

export default function App() {
  const [expanded, setExpanded] = useState(false);
  const [ref, bounds] = useMeasure();

  return (
    <div className={styles.container}>
      <MotionConfig
        transition={{
          duration: 0.4,
          ease: [0.19, 1, 0.22, 1],
          delay: 0.05,
        }}
      >
        <div className={styles.card}>
          <motion.div
            animate={{
              height: bounds.height > 0 ? bounds.height : "auto",
            }}
            className={styles.content}
          >
            <div ref={ref} className={styles.inner}>
              <p className={styles.text}>
                Containers on the web snap to their new size instantly when
                content changes. By measuring the bounds of a container and
                animating to those values, we can make these transitions feel
                smooth and intentional.
              </p>
              <AnimatePresence mode="popLayout">
                {expanded && (
                  <motion.p
                    initial={{ opacity: 0, filter: "blur(8px)" }}
                    animate={{ opacity: 1, filter: "blur(0px)" }}
                    exit={{ opacity: 0, filter: "blur(8px)" }}
                    className={styles.text}
                  >
                    This technique uses a ref to track the height of the inner
                    content. When the content changes, the measured height
                    updates and Motion animates the outer container to match.
                    The inner div always has its natural height, so the content
                    is never clipped or distorted.
                  </motion.p>
                )}
              </AnimatePresence>
            </div>
          </motion.div>
          <button
            type="button"
            className={styles.button}
            onClick={() => setExpanded((prev) => !prev)}
          >
            {expanded ? "Show Less" : "Read More"}
          </button>
        </div>
      </MotionConfig>
    </div>
  );
}

When items are added or removed, the measured height changes and the animation follows. Works for any dynamic content, text, images, lists, nested components.

Gotchas

On initial render, bounds.width and bounds.height will be 0 before the first measurement. Guard against this or you'll get an animation from 0 to the actual size on mount. A simple bounds.height > 0 ? bounds.height : "auto" works.

Don't measure and animate the same element. The ref goes on the inner div, the animate prop goes on the outer div. If you put both on the same element you create a loop, the animation changes the size, which triggers a new measurement, which triggers a new animation.

Add a small delay to the animation to give the feel of a natural transition catching up to the content.

Finally, don't overuse this pattern. It's a subtle effect that should be used sparingly. Use it when it makes sense, like for buttons, accordions, or other interactive elements.


  1. ResizeObserver is supported in all modern browsers. It watches for changes to an element's content or border box size without causing layout thrashing.

  2. react-use-measure is a popular alternative maintained by the Poimandres team. It adds some extras like debouncing and scroll tracking, but for most cases the hook above is all you need.