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.
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.
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:
Transition grid-template-rows from 0fr to 1fr. Best for open and closed.
Measure the content and transition a pixel height. Best when the content keeps changing.
Give every state a known size. Best when you design the states yourself.
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.
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.tsximport 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.
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.
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.tsximport { 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> );}
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.
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.
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.
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.
transition-all on an element that goes from h-0 to h-auto. It never animates, for the reason above.
The max-height hack, with a lag on close and content clipped on long panels.
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.
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.
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.
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.
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.