How to Animate Height Auto in React and Tailwind CSS

6 min read

TL;DR Height can't transition to auto because auto isn't a number. Wrap the content in a grid and transition grid-template-rows from 0fr to 1fr, with min-h-0 on the child and inert when closed. For content that grows while open, measure it with a ResizeObserver and transition a pixel height. interpolate-size: allow-keywords makes auto work directly, but not in every browser yet.

On this page
  1. Why height: auto snaps
  2. The grid trick for open and closed
  3. An accordion on top of it
  4. When the content keeps changing size
  5. Known sizes: what the Dynamic Island does
  6. The new CSS way: interpolate-size
  7. Four bugs in AI-generated expanding panels
  8. FAQ
  9. Why does my height transition snap instead of animating?
  10. Is the grid trick worse for performance than max-height?
  11. Does this work with Tailwind v3?
  12. Where can I see a component that changes size like this?

To animate height: auto in React and Tailwind, wrap the content in a grid and transition grid-template-rows from 0fr to 1fr. That works in every current browser and needs no JavaScript. When the content changes size while it's open, like search results arriving, measure it with a ResizeObserver and transition a pixel height instead. The new CSS interpolate-size: allow-keywords lets you transition straight to auto, but treat it as an extra for now.

I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Dynamic Island Navbar is a black pill that grows into a notification, a music player and a search field, so changing size smoothly is most of what it does. The code in this post was written for the post, not copied from that component, and I'll say where the component does something different.

Why height: auto snaps

CSS can only transition between two values it can calculate the in-between of. 0px to 240px has a middle. 0px to auto doesn't, because auto isn't a number until layout runs, so the browser jumps to the end. That's why an AI-written accordion often has a perfectly good transition-all class that does nothing.

There are four ways around it:

  1. Transition grid-template-rows from 0fr to 1fr. Best for open and closed.
  2. Measure the content and transition a pixel height. Best when the content keeps changing.
  3. Give every state a known size. Best when you design the states yourself.
  4. Use interpolate-size: allow-keywords. Nicest to write, but not supported everywhere yet.

The usual shortcut, max-height: 1000px, is worth skipping. The browser spreads the transition over the full 1000px, so a 200px panel finishes opening in a fifth of the time you set and waits before it starts closing. And anything taller than your guess gets cut off.

Dynamic Island Navbar running live. A pill navbar that morphs for notifications, music and search.

The grid trick for open and closed

A grid row sized 0fr takes no space, and 1fr takes the height of its content. Browsers can interpolate between the two, so the row grows to exactly the content's height. The child needs min-h-0 (or overflow-hidden), or it will refuse to shrink below its content.

tsx
// Collapse.tsx
import type { ReactNode } from "react";

type CollapseProps = {
  open: boolean;
  children: ReactNode;
};

export function Collapse({ open, children }: CollapseProps) {
  return (
    <div
      inert={!open}
      className={`grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none ${
        open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
      }`}
    >
      <div className="min-h-0 overflow-hidden">{children}</div>
    </div>
  );
}

Three things in there matter more than they look:

  • No hooks, so this isn't a client component. It renders on the server in the right state, with nothing to measure and nothing to flash on load.
  • inert takes the closed content out of the tab order and away from screen readers. Without it, a keyboard user can tab into links inside a panel they can't see. React 19 accepts inert as a boolean; on React 18 you'll need a workaround.
  • motion-reduce:transition-none makes it open instantly for people who turned on reduced motion. More on that in prefers-reduced-motion in React.

An accordion on top of it

The W3C accordion pattern asks for a button inside a heading, aria-expanded on the button, and aria-controls pointing at the panel. The panel can be a region labelled by its button.

tsx
// Accordion.tsx
"use client";

import { useId, useState } from "react";
import { Collapse } from "./Collapse";

type Item = { question: string; answer: string };

export function Accordion({ items }: { items: Item[] }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  const baseId = useId();

  return (
    <div className="divide-y divide-white/10 rounded-2xl border border-white/10">
      {items.map((item, i) => {
        const open = openIndex === i;
        const buttonId = `${baseId}-button-${i}`;
        const panelId = `${baseId}-panel-${i}`;

        return (
          <div key={item.question}>
            <h3>
              <button
                id={buttonId}
                type="button"
                aria-expanded={open}
                aria-controls={panelId}
                onClick={() => setOpenIndex(open ? null : i)}
                className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left font-medium focus-visible:outline-2 focus-visible:outline-offset-2"
              >
                {item.question}
                <span
                  aria-hidden="true"
                  className={`text-xl transition-transform duration-300 motion-reduce:transition-none ${
                    open ? "rotate-45" : ""
                  }`}
                >
                  +
                </span>
              </button>
            </h3>
            <Collapse open={open}>
              <div
                id={panelId}
                role="region"
                aria-labelledby={buttonId}
                className="px-5 pb-4 text-white/70"
              >
                {item.answer}
              </div>
            </Collapse>
          </div>
        );
      })}
    </div>
  );
}

useId keeps the ids the same on the server and the client, so there's no hydration mismatch. Only one panel opens at a time here; if you want several, keep a Set of open indexes instead of one number.

Using it from a Server Component is plain:

tsx
// page.tsx
import { Accordion } from "./Accordion";

const faqs = [
  { question: "Do I get the source?", answer: "Yes. TSX, plain HTML and a prompt file." },
  { question: "Can I use it for clients?", answer: "Yes, the licence covers client work." },
];

export default function Page() {
  return (
    <main className="mx-auto max-w-xl p-8">
      <h2 className="mb-4 text-2xl font-semibold">Questions</h2>
      <Accordion items={faqs} />
    </main>
  );
}

When the content keeps changing size

The grid trick handles open and closed. It doesn't help when the content grows while it's already open: a list of search results, an error message under a form, a card that loads more text. For that, measure the content and give its wrapper a real pixel height, which the browser can transition.

tsx
// AutoHeight.tsx
"use client";

import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

type AutoHeightProps = {
  children: ReactNode;
  className?: string;
};

export function AutoHeight({ children, className = "" }: AutoHeightProps) {
  const innerRef = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | null>(null);

  useLayoutEffect(() => {
    const inner = innerRef.current;
    if (!inner) return;

    const observer = new ResizeObserver((entries) => {
      const size = entries[0]?.borderBoxSize?.[0];
      setHeight(size ? size.blockSize : inner.offsetHeight);
    });
    observer.observe(inner);
    return () => observer.disconnect();
  }, []);

  return (
    <div
      style={{ height: height ?? "auto" }}
      className={`overflow-hidden transition-[height] duration-500 ease-[cubic-bezier(.32,1.32,.5,1)] motion-reduce:transition-none ${className}`}
    >
      <div ref={innerRef} className="flow-root">
        {children}
      </div>
    </div>
  );
}

How it behaves:

  • On the server and on first paint the height is auto, so the content shows at its natural size with no jump.
  • After that, every time the inner box changes size, the outer box gets the new pixel height and slides to it.
  • flow-root on the inner box keeps a child's margin inside it, so the measured height matches what you see.
  • The curve overshoots a little, then settles. Swap in ease-out if you want it calmer.

A search box that grows as results come in:

tsx
// SearchBox.tsx
"use client";

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

const pages = ["Billing", "Billing history", "Branding", "Team", "Team roles", "Tokens"];

export function SearchBox() {
  const [query, setQuery] = useState("");
  const q = query.trim().toLowerCase();
  const results = q ? pages.filter((p) => p.toLowerCase().includes(q)) : [];

  return (
    <div className="w-80 rounded-3xl bg-black p-3 text-white">
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search settings"
        aria-label="Search settings"
        className="w-full rounded-2xl bg-white/10 px-4 py-2 outline-none"
      />
      <AutoHeight>
        <ul className="pt-2">
          {results.map((r) => (
            <li key={r} className="rounded-xl px-4 py-2 hover:bg-white/10">
              {r}
            </li>
          ))}
        </ul>
      </AutoHeight>
    </div>
  );
}

Animating height makes the browser redo layout on every frame, for the box and everything below it. On a small box near the bottom of the page that's fine. On a long list near the top of a heavy page, keep the duration short or animate a transform instead.

Known sizes: what the Dynamic Island does

The island takes the third route. Every state is designed up front, so the plain HTML version keeps a small table with a width, height and corner radius for each one (idle, notification, music, search) and sets them as inline pixel values. The pill transitions width and height over 0.6 s with cubic-bezier(.32,1.32,.5,1), which is the slight overshoot you see when it opens. Search is the one state whose content isn't fixed, so it adds extra height as results drop in. The content inside cross-fades with a short blur while the shape changes, which hides the moment where the old content no longer fits and the new one doesn't fit yet. The React version in the download is built with Framer Motion.

If you control the states, this is the simplest route of the four. There's nothing to measure and nothing to flash, and the shape ends up exactly where you drew it.

The new CSS way: interpolate-size

CSS now has a switch that makes auto transitionable:

css
:root {
  interpolate-size: allow-keywords;
}

.panel {
  height: 0;
  overflow: hidden;
  transition: height 300ms ease-out;
}

.panel[data-open="true"] {
  height: auto;
}

Set it once on :root and it's inherited, so every height: auto transition on the page works. For a single element there's the calc-size() function, for example height: calc-size(auto, size).

The catch: MDN lists both interpolate-size and calc-size() as limited availability, meaning they don't work in some of the most widely used browsers yet. Where they're missing, the panel opens instantly, which is a fine fallback. I'd still ship the grid version for anything people rely on and treat this as a nicer path for browsers that have it.

Four bugs in AI-generated expanding panels

  1. transition-all on an element that goes from h-0 to h-auto. It never animates, for the reason above.
  2. The max-height hack, with a lag on close and content clipped on long panels.
  3. The closed panel is hidden with height 0 but its links and inputs can still be tabbed to. Add inert, or hidden if you don't need the closing animation.
  4. The panel is rendered with open && <Panel />, so it vanishes on close before any animation can run. Keep it mounted and animate it closed.

When you ask Cursor, v0 or Lovable for an accordion or a growing card, say it up front: "animate open and close with a grid-template-rows 0fr to 1fr transition, keep the panel mounted, add inert when closed, aria-expanded and aria-controls on the button, and turn the transition off under prefers-reduced-motion". You'll get something close to the code above on the first try. For the rest of the accessibility checklist on animated UI, see shadcn/ui animations and accessibility.

FAQ

Why does my height transition snap instead of animating?

Because one end of it is auto. The browser can't calculate the frames between a number and a keyword, so it jumps. Use the grid 0fr to 1fr trick, measure the content and transition a pixel value, or turn on interpolate-size: allow-keywords in browsers that support it.

Is the grid trick worse for performance than max-height?

Both change layout on every frame, so they cost about the same. The grid version just gets the timing right because the row's end size is the real content height. If you need to animate something large often, a transform such as scaleY is cheaper, but it squashes the content, so it suits backgrounds more than text.

Does this work with Tailwind v3?

Yes. grid-rows-[0fr], transition-[grid-template-rows], transition-[height] and the arbitrary ease-[cubic-bezier(...)] are all arbitrary values, which v3 supports. In v3, add focus-visible:outline next to focus-visible:outline-2 so the focus ring is solid.

Where can I see a component that changes size like this?

The Dynamic Island Navbar grows from a pill into a notification, a music player and a search field that gets taller as results arrive, with the content cross-fading inside. There's a walkthrough in our Dynamic Island navbar guide. For a sliding indicator built the same no-library way, see accessible animated tabs in React, and if you're weighing CSS against a motion library, Framer Motion vs CSS for micro-interactions.