Accordion in React: Radix vs Custom — Animated Height Transitions
Radix UI accordion vs rolling your own in React — animated height transitions, keyboard nav, and when each approach actually makes sense in 2026.
Why Accordion Animation Is Harder Than It Looks
You'd think animating an accordion is trivial. Open, close, done. Except height: auto doesn't animate. Never has. CSS can't tween between a fixed pixel value and auto — that's just not how the spec works, and it's caught developers off guard since at least 2015.
So you've got two broad choices: fake it with max-height, or measure the real height in JavaScript and set an explicit pixel value. Both work. Both have trade-offs. And then there's the third option — reach for Radix UI's Accordion primitive and let someone else handle the edge cases.
In practice, max-height hacks feel fine until you have content that varies wildly in length. Set it too high and your easing curve compresses at the end, making the close feel sluggish. Set it too low and your content gets clipped on mobile when the user bumps the font size.
This article covers all three paths: the quick max-height hack, a proper JS-measured approach with useRef, and Radix UI's built-in solution. We'll wire up Tailwind for styling. By the end you'll know exactly which to pick for your project.
The max-height Trick — Quick but Flawed
For a lot of projects, the max-height approach is genuinely fine. Open an item, set max-height to something absurdly large like 1000px, transition it, done. You can ship this in under 20 lines of JSX.
That said, the animation curve lies to the user. You're animating from 0 to 1000px even if the actual content is only 200px tall. That means roughly 80% of your ease-in-out curve completes before anything visible happens on close. Worth noting: this becomes genuinely jarring at transition durations above 300ms.
Here's the minimal version with Tailwind:
import { useState } from 'react';
function Accordion({ title, children }) {
const [open, setOpen] = useState(false);
return (
<div className="border border-white/10 rounded-lg overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex justify-between items-center px-5 py-4 text-left font-medium"
>
{title}
<span className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}>
▾
</span>
</button>
<div
className="transition-all duration-300 ease-in-out overflow-hidden"
style={{ maxHeight: open ? '600px' : '0px' }}
>
<div className="px-5 pb-4">{children}</div>
</div>
</div>
);
}Quick aside: if your accordion items will never hold more than a paragraph or two, this is honestly good enough. Don't over-engineer it.
The Proper Way — Measuring Real Height with useRef
When you need a physically accurate animation, you measure. A useRef on the inner content div gives you the real scrollHeight, which you then apply as an inline height style. The transition animates between 0px and that pixel value. Clean.
The key insight is that scrollHeight is always available even when the element is visually hidden — as long as it's in the DOM. You're not guessing anymore. The easing curve maps perfectly to the actual content movement.
import { useState, useRef, useEffect } from 'react';
function Accordion({ title, children }) {
const [open, setOpen] = useState(false);
const contentRef = useRef(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(open ? contentRef.current.scrollHeight : 0);
}
}, [open]);
return (
<div className="border border-white/10 rounded-lg overflow-hidden">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="w-full flex justify-between items-center px-5 py-4 text-left font-medium"
>
{title}
<span
className="transition-transform duration-200"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
▾
</span>
</button>
<div
ref={contentRef}
className="transition-[height] duration-300 ease-in-out overflow-hidden"
style={{ height: `${height}px` }}
>
<div className="px-5 pb-4">{children}</div>
</div>
</div>
);
}One more thing — notice aria-expanded on the button. That's not optional if you care about screen readers. The max-height example above omitted it; this version doesn't. Small fix, real accessibility win.
The only real downside here is that if the content changes height while open — say, an image loads async — your measured height is stale. You can patch this with a ResizeObserver, but at that point you're basically rebuilding what Radix already ships.
Radix UI Accordion — What You Actually Get
Radix UI's @radix-ui/react-accordion (v1.2.x as of writing) gives you an unstyled, accessible accordion with built-in animated height via a CSS custom property. No JavaScript height measurement. Keyboard navigation out of the box. ARIA attributes wired up correctly. It's genuinely well-built.
The trick Radix uses is a --radix-accordion-content-height CSS variable it sets on the content element before the animation runs. You reference that variable in your Tailwind keyframes. This keeps the animation pure CSS while using actual measured height under the hood.
npm install @radix-ui/react-accordionimport * as Accordion from '@radix-ui/react-accordion';
function FAQAccordion({ items }) {
return (
<Accordion.Root type="single" collapsible className="space-y-2">
{items.map((item) => (
<Accordion.Item
key={item.id}
value={item.id}
className="border border-white/10 rounded-lg overflow-hidden"
>
<Accordion.Trigger className="w-full flex justify-between items-center px-5 py-4 text-left font-medium group">
{item.question}
<span className="transition-transform duration-200 group-data-[state=open]:rotate-180">
▾
</span>
</Accordion.Trigger>
<Accordion.Content className="overflow-hidden data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up">
<div className="px-5 pb-4">{item.answer}</div>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
}Then in your tailwind.config.js:
``js
module.exports = {
theme: {
extend: {
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
};
``
Honestly, if you're on a product with real users, just use Radix. The keyboard nav alone (Home/End keys, arrow navigation) is worth the dependency. You'd spend a weekend re-implementing that correctly on your own.
Styling Your Accordion to Match Your Design System
Both approaches above are intentionally unstyled. That's the point. You own the CSS. Whether you're building something with a glassmorphism aesthetic or going full neobrutalism, the accordion itself doesn't care.
For glassmorphism accordions specifically, you want backdrop-filter: blur(12px) on the panel, a 1px white/10% border, and a very subtle bg-white/5 background. Keep the transition timing around 250ms — anything slower starts feeling sluggish on lower-powered Android devices.
If you're using one of the templates from Empire UI, the accordion component there uses the Radix approach with the CSS variable trick. The styling is already wired up, so you can drop it straight in without configuring keyframes from scratch.
One more thing — the chevron rotation. A lot of developers animate the chevron with JS toggling a class. Use group-data-[state=open]:rotate-180 with Tailwind instead. It reads the actual Radix state attribute, so it's always in sync even if you programmatically control the accordion's open state.
When to Use Radix vs Custom
Here's the honest decision tree. Build custom if: it's a one-off UI element with no keyboard nav requirements, you can't add another dependency, or you need sub-5kb bundle impact for a landing page performance budget.
Use Radix if: you're building a product, multiple developers touch the codebase, or you need ARIA compliance without manually auditing your implementation. The bundle cost of @radix-ui/react-accordion is around 8kb gzipped. That's not nothing, but it's not the reason your Core Web Vitals are suffering either.
Worth noting: shadcn/ui ships its Accordion component built directly on Radix with the CSS variable animation. If you're already on shadcn, you're already using Radix — you just might not have noticed. Check if it's already in your project before reaching for a custom implementation.
You can also check out the box shadow generator and gradient generator on Empire UI for styling the expanded panel state — particularly useful when you want the open item to visually pop from the rest of the list.
FAQ
CSS can't interpolate between a fixed length and a keyword value like auto. The spec simply doesn't define it. You need either a concrete pixel value on both ends, or the max-height workaround.
The Radix accordion requires client-side interactivity, so you'll need to add 'use client' to any component that uses it. The primitive itself is fine in Next.js 13+ App Router — just mark the wrapper component correctly.
On Accordion.Root, set type="single". Add collapsible if you also want users to be able to close the currently open item by clicking it again. For multi-open, use type="multiple".
Yes — wrap Accordion.Content in a motion.div and animate the height property from 0 to 'auto' using Framer Motion's layout animations or the AnimatePresence + initial={false} pattern. It pairs cleanly with Radix.