# How to Animate Height Auto in React and Tailwind CSS

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

- URL: https://qodesign.dev/blog/animate-height-auto-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/dynamic-island) 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.

## 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](/blog/prefers-reduced-motion-react).

## An accordion on top of it

The [W3C accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) 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](https://developer.mozilla.org/en-US/docs/Web/CSS/calc-size), for example `height: calc-size(auto, size)`.

The catch: MDN lists both [interpolate-size](https://developer.mozilla.org/en-US/docs/Web/CSS/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](/blog/shadcn-ui-animation-accessibility-reduced-motion-focus).

## 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](/components/dynamic-island) 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](/react/dynamic-island-navbar). For a sliding indicator built the same no-library way, see [accessible animated tabs in React](/blog/accessible-animated-tabs-react-tailwind-no-library), and if you're weighing CSS against a motion library, [Framer Motion vs CSS for micro-interactions](/blog/framer-motion-vs-css-micro-interactions).
