Animating Height Changes in React

Nobody likes choppy layout shifts. This article shows how to add smooth collapsing/expanding effects to animate height changes for a better user experience.

Published on

2 min read

How height shifts affect user experience
How height shifts affect user experience

Nobody likes choppy layout shifts while interacting with a website. In this article, we will add a smooth collapsing/expanding effect to animate the height changes.

Scenario

First, let's create a height change scenario. A simple element that controls the max height of the div.

import { useState } from "react";

export default function Example() {
  const [showFirst, setShowFirst] = useState(true);
  return (
    <>
      <div>
        {showFirst
          ? "This is the first Text. "
          : "Second Text has many lines. Lorem ipsum dolor sit amet consectetur adipisicing elit. Tenetur similique ab saepe harum et magnam cupiditate esse dolores amet deleniti?"}
      </div>
      <button onClick={() => setShowFirst((f) => !f)}>Show {showFirst ? "Second" : "First"}</button>
    </>
  );
}

Step 1: Animate with height

We need to wrap the element with a new div, we can use this parent div to control the height via useState.

const [contentHeight, setContentHeight] = useState();
  return (
    <>
      <div style={{
        height: contentHeight,
        transition: "height 200ms ease-out",
        overflow: "hidden"
      }}>
        <div>
          {showFirst
            ? "This is the first Text. "
            : "Second Text has many lines. " +
              "Lorem ipsum dolor sit amet consectetur adipisicing elit. " +
              "Tenetur similique ab saepe harum et magnam cupiditate esse dolores amet deleniti?"}
        </div>
      </div>
      <button onClick={() => setShowFirst((f) => !f)}>Show {showFirst ? "Second" : "First"}</button>
    </>
  );

Step 2: Observe the height change

Now that the animation is ready, we need to observe the height change of the child div. Luckily, we can use resizeObserver. To make this happen, let's add a useRef to reference the child div, so the resizeObserver can access it.

const childRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const resizeObserver = new ResizeObserver((entries) => {
      const height = entries[0].contentRect.height; // we only need the first entry
      setContentHeight(height);
    });
    if (childRef.current) resizeObserver.observe(childRef.current);
    () => resizeObserver.disconnect();
  }, []);

  return (
    <>
      <div
        style={{
          height: contentHeight,
          transition: "height 200ms ease-out",
          overflow: "hidden",
        }}
      >
        <div ref={childRef}> {/* Pass ref to the child div */}
          {showFirst
            ? "This is the first Text. "
            : "Second Text has many lines. " +
              "Lorem ipsum dolor sit amet consectetur adipisicing elit. " +
              "Tenetur similique ab saepe harum et magnam cupiditate esse dolores amet deleniti?"}
      </div>
      <button onClick={() => setShowFirst((f) => !f)}>Show {showFirst ? "Second" : "First"}</button>
    </>
  );

After linking the childRef and resizeObserver, we will let the observer know when the child div resizes, set the height with setContentHeight, the animation will do the rest!

Animated height on content change
Animated height on content change

Here is the full code:

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

export default function Example() {
  const [showFirst, setShowFirst] = useState(false);
  const [contentHeight, setContentHeight] = useState<number>();

  const childRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const resizeObserver = new ResizeObserver((entries) => {
      const height = entries[0].contentRect.height; // we only need the first entry
      setContentHeight(height);
    });
    if (childRef.current) resizeObserver.observe(childRef.current);
    () => resizeObserver.disconnect();
  }, []);

  return (
    <>
      <div
        style={{
          height: contentHeight,
          transition: "height 200ms ease-out",
          overflow: "hidden",
        }}
      >
        <div ref={childRef}> {/* Pass ref to the child div */}
          {showFirst
            ? "This is the first Text. "
            : "Second Text has many lines. " +
              "Lorem ipsum dolor sit amet consectetur adipisicing elit. " +
              "Tenetur similique ab saepe harum et magnam cupiditate esse dolores amet deleniti?"}
        </div>
      </div>
      <button onClick={() => setShowFirst((f) => !f)}>Show {showFirst ? "Second" : "First"}</button>
    </>
  );
}

Bonus Step: Make it a component

We can always reuse code like this, so here is the extracted component:

import { ReactNode, useEffect, useRef, useState } from "react";

export default function AnimatedHeight(props: { children: ReactNode }) {
  const [contentHeight, setContentHeight] = useState<number>();
  const childRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const resizeObserver = new ResizeObserver((entries) => {
      const height = entries[0].contentRect.height;
      setContentHeight(height);
    });
    if (childRef.current) resizeObserver.observe(childRef.current);
    () => resizeObserver.disconnect();
  }, []);

  return (
    <div
      style={{
        height: contentHeight,
        transition: "height 200ms ease-out",
        overflow: "hidden",
      }}
    >
      <div ref={childRef}>{props.children}</div>
    </div>
  );
}

Import and use AnimatedHeight

import { useState } from "react";
import AnimatedHeight from "./AnimatedHeight";

export default function Example() {
  const [showFirst, setShowFirst] = useState(false);
  return (
    <>
      <AnimatedHeight>
        {showFirst
          ? "This is the first Text. "
          : "Second Text has many lines. " +
            "Lorem ipsum dolor sit amet consectetur adipisicing elit. " +
            "Tenetur similique ab saepe harum et magnam cupiditate esse dolores amet deleniti?"}
      </AnimatedHeight>
      <button onClick={() => setShowFirst((f) => !f)}>Show {showFirst ? "Second" : "First"}</button>
    </>
  );
}