EmpireUI
Get Pro
← Blog7 min read#react#accordion#tailwind

React Accordion Patterns: 6 Variants from Simple to Animated

Six React accordion patterns — from zero-dependency basics to animated, glassmorphic, and nested variants. Real code, Tailwind v4 classes, no fluff.

Stacked UI panels with expanding sections on a dark developer workspace

Why Accordion Components Are Harder Than They Look

Honestly, the accordion is one of those components devs underestimate every single time. You think: expand a div, rotate an icon, done. Then two weeks later you're debugging focus traps, broken keyboard navigation, ARIA attributes that don't survive a re-render, and an animation that janks because height: auto doesn't transition.

The good news: React's composition model and modern CSS make all six patterns below totally tractable. You don't need a bloated library to get smooth, accessible, production-ready accordions. You need the right mental model for each variant.

We're covering six patterns in increasing complexity: the zero-dep vanilla React accordion, the CSS-only version, the Tailwind v4 animated version, the Radix UI primitive build, the glassmorphic variant, and finally a nested/multi-level accordion. Each one fits a different project context — pick what matches yours.

Pattern 1: Zero-Dependency React Accordion with useState

No external dependencies. Just useState, an array of items, and conditional rendering. It's ugly in production but it's the foundation every other pattern builds on. Understanding it means you'll actually know what the fancier abstractions are doing.

The biggest trap here is managing 'which panel is open' with a single index versus an array of booleans. Use an index (or null) for single-open behavior, and a Set<number> for multi-open. Don't reach for an array of booleans — updating it correctly requires spreading and reconstructing the whole array every time.

import { useState } from 'react';

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

const items: Item[] = [
  { id: 1, question: 'What is Empire UI?', answer: 'A free, open-source React & Tailwind component library with 40 visual styles.' },
  { id: 2, question: 'Does it support dark mode?', answer: 'Yes. Every component includes dark-mode variants via the ThemeProvider.' },
];

export function BasicAccordion() {
  const [openId, setOpenId] = useState<number | null>(null);

  return (
    <div className="flex flex-col gap-2">
      {items.map((item) => (
        <div key={item.id} className="border border-neutral-200 rounded-lg dark:border-neutral-700">
          <button
            onClick={() => setOpenId(openId === item.id ? null : item.id)}
            aria-expanded={openId === item.id}
            className="w-full text-left px-4 py-3 font-medium flex justify-between items-center"
          >
            {item.question}
            <span className={`transition-transform ${openId === item.id ? 'rotate-180' : ''}`}>▾</span>
          </button>
          {openId === item.id && (
            <div className="px-4 pb-4 text-sm text-neutral-600 dark:text-neutral-400">
              {item.answer}
            </div>
          )}
        </div>
      ))}
    </div>

  );
}

Note the aria-expanded on the button — that's the single most important accessibility attribute here. Without it, screen readers can't tell users whether a panel is open or closed. Don't skip it, even in prototypes.

Pattern 2: CSS-Only Accordion Using the details Element

If you don't need React state at all, the <details> and <summary> HTML elements are your friends. Browsers handle open/close natively. No JavaScript required. It's the right call for static content pages, marketing sites, or any context where JS adds overhead without benefit.

The catch: <details> styling is inconsistent across browsers, and animating the open/close transition requires a bit of creativity. You can use max-height transitions on the inner content, but you're fighting against the browser's default disclosure behavior. The cleanest workaround is wrapping content in an inner <div> and animating that.

details > summary {
  list-style: none;
  cursor: pointer;
  padding: 12px 16px;
  font-weight: 500;
  display: flex;
  justify-content: space-between;
}

details > summary::after {
  content: '▾';
  transition: transform 200ms ease;
}

details[open] > summary::after {
  transform: rotate(180deg);
}

details .content {
  overflow: hidden;
  max-height: 0;
  transition: max-height 300ms ease;
}

details[open] .content {
  max-height: 400px; /* set to a value safely above your content height */
}

The max-height trick is imperfect — if your content is taller than 400px it clips, and the animation timing looks off if the real height is much shorter than the max. But for FAQ sections with predictable content lengths, it works fine and ships zero JS.

Pattern 3: Tailwind v4 Animated Accordion with CSS Grid

The cleanest animation technique for accordions in 2026 is transitioning grid-template-rows from 0fr to 1fr. It replaced the max-height hack because it respects actual content height. Tailwind v4.0.2 doesn't include utility classes for grid-template-rows animation by default, so you'll add a small @layer utilities block.

The pattern pairs a grid wrapper around the content with overflow-hidden on the inner div. When the accordion opens, the grid row expands to fit the content naturally. No magic numbers. No clipping. The transition duration we use is 300ms with ease-out — snappy enough to feel responsive, slow enough that users see the motion.

import { useState } from 'react';

function AccordionItem({ question, answer }: { question: string; answer: string }) {
  const [open, setOpen] = useState(false);

  return (
    <div className="border-b border-neutral-200 dark:border-neutral-800">
      <button
        onClick={() => setOpen(!open)}
        aria-expanded={open}
        className="w-full flex justify-between items-center py-4 px-0 text-left font-medium text-neutral-900 dark:text-neutral-100"
      >
        {question}
        <svg
          className={`w-4 h-4 shrink-0 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
          viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
        >
          <path d="M6 9l6 6 6-6" />
        </svg>
      </button>
      <div
        className="grid transition-all duration-300 ease-out"
        style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
      >
        <div className="overflow-hidden">
          <p className="pb-4 text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
            {answer}
          </p>
        </div>
      </div>
    </div>
  );
}

This same grid-template-rows trick works well in animated tabs too. The mental model is identical: a grid row that can shrink to zero height while the inner content stays in flow. It's a technique worth learning once and applying everywhere.

Pattern 4: Accessible Accordion with Radix UI Primitives

Radix UI's @radix-ui/react-accordion package handles all the accessibility spec for you — keyboard navigation (Arrow Up/Down, Home, End), ARIA attributes, focus management, and the WAI-ARIA Accordion pattern. If you're building a product that needs to pass accessibility audits, start here instead of rolling your own.

The tradeoff is bundle size and an abstraction layer you don't fully control. The package is ~8KB gzipped. For most apps that's irrelevant. Where it matters is in component libraries or micro-frontend architectures where you're shipping UI across dozens of independently-deployed apps.

Installation is npm install @radix-ui/react-accordion. The API is clean: Accordion.Root, Accordion.Item, Accordion.Trigger, and Accordion.Content. You bring your own styles. Combine it with Tailwind and the data-[state=open] selector for clean conditional styling without any JavaScript for the open state.

Why bother with Radix when the simpler patterns work? Because keyboard users and screen reader users exist, they use your product, and testing your accordion with a keyboard alone will show you how many edge cases the Radix team already solved. The type="single" vs type="multiple" prop covers both single- and multi-open use cases with no extra code on your end.

Pattern 5: Glassmorphic Accordion Variant

The glassmorphic accordion is the visual standout. It uses backdrop-filter: blur(12px), a semi-transparent background at rgba(255,255,255,0.08) for dark mode, and a 1px border at rgba(255,255,255,0.15). The result is a frosted-glass panel that floats over a gradient or image background. If you want the full theory behind why this works, what is glassmorphism breaks it down.

The tricky part is that backdrop-filter requires the element's background to be at least partially transparent. If you accidentally set background-color: white or use an opaque Tailwind class like bg-white, the blur effect disappears. Always use bg-white/8 (Tailwind's opacity modifier) or an rgba value with alpha below 1.

function GlassAccordionItem({ question, answer }: { question: string; answer: string }) {
  const [open, setOpen] = useState(false);

  return (
    <div
      className="rounded-xl mb-3 border"
      style={{
        background: 'rgba(255,255,255,0.08)',
        borderColor: 'rgba(255,255,255,0.15)',
        backdropFilter: 'blur(12px)',
        WebkitBackdropFilter: 'blur(12px)',
      }}
    >
      <button
        onClick={() => setOpen(!open)}
        aria-expanded={open}
        className="w-full text-left px-5 py-4 flex justify-between items-center text-white font-medium"
      >
        {question}
        <span className={`transition-transform duration-250 inline-block ${open ? 'rotate-180' : ''}`}>▾</span>
      </button>
      <div
        className="grid transition-all duration-300 ease-out"
        style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
      >
        <div className="overflow-hidden">
          <p className="px-5 pb-4 text-sm text-white/70 leading-relaxed">{answer}</p>
        </div>
      </div>
    </div>
  );
}

Pair this with a gradient or blurred image background and you get the premium feel that's become standard in SaaS landing pages. It's also a natural companion to cards with stack effects if you're building a visually layered interface.

Pattern 6: Nested Accordion for Multi-Level Content

Nested accordions — accordions inside accordion panels — sound simple but create subtle problems. The main one: which useState owns what. If you manage open state in a parent component, you can end up with O(n²) re-renders as the tree deepens. The fix is to co-locate state inside each AccordionItem component, making each panel self-managing.

A second issue is visual hierarchy. Nested panels need 8px left padding and a slightly reduced font size (from text-sm to text-xs) to signal depth without explicit nesting indicators. Don't add infinite depth — two levels maximum. Three levels deep and your users are lost.

The recursive component pattern works well here. Define AccordionItem to accept children as an optional prop, and when children are present, render another AccordionList inside the open panel. TypeScript types keep the structure disciplined. The only runtime concern is performance: if you have 50+ items per level, consider virtualization — otherwise React renders every item in the tree on every keystroke.

Choosing the Right Accordion Pattern for Your Project

So which one do you actually reach for? Static content with no JS budget: <details>. Quick prototype or simple FAQ: useState with inline conditional rendering. Production app needing accessibility compliance: Radix UI. Visually rich landing page: glassmorphic variant with the grid-template-rows animation.

The Tailwind animated pattern (Pattern 3) is the most generally useful. It works in 95% of cases, adds no dependencies, animates correctly, and is readable by any mid-level React dev on your team. If you're also building navigation elements, the same animation model applies to animated tabs — it's worth reading that piece alongside this one.

One thing that trips people up: mixing patterns in the same app. If you've got Radix accordion in your design system but you roll your own in a feature component, you'll ship two different interaction models and two different ARIA implementations. Pick one and stick with it. Consistency beats novelty every time.

Performance and Accessibility Checklist Before You Ship

Accessibility first: every accordion trigger must be a <button> element — not a <div> with an onClick. Keyboard users need Tab to reach it and Enter/Space to activate it. The aria-expanded attribute must toggle with the open state. The content panel should be associated with the trigger via aria-controls pointing to the panel's id. Without these, you're shipping something that works for mouse users and breaks for everyone else.

Performance: avoid animating height directly. The grid-template-rows technique triggers layout only on the grid container, which is cheaper than animating height on every child. If you've got more than 20 accordion items visible at once — say in a long documentation sidebar — consider React.memo on AccordionItem to avoid re-rendering every item when one panel opens.

Finally, test with a theme toggle. If you're using Empire UI's theme toggle component, make sure your accordion border colors and background opacities look right in both light and dark mode. rgba(255,255,255,0.15) looks great in dark mode and nearly invisible in light mode — use Tailwind's dark: prefix to swap values at the right breakpoint.

FAQ

Why doesn't `height: auto` work in CSS transitions for accordions?

CSS can't interpolate between a fixed value and auto — it's not a valid transition target. The workaround options are: max-height with a large fixed value (imprecise timing), JavaScript-measured explicit heights (runtime overhead), or grid-template-rows: 0fr to 1fr (the cleanest modern solution).

How do I make only one accordion panel open at a time?

Store the open panel's ID (or index) in a single useState<string | null>(null) at the parent level. When a panel is clicked, set state to its ID if it's closed, or to null if it's already open. Each panel reads from this shared state and sets aria-expanded accordingly.

Does the `grid-template-rows` animation work in Safari?

Yes, as of Safari 16+ (released September 2022). For older Safari versions you'd need the max-height fallback. In 2026 this is a non-issue for essentially all production traffic, but check your analytics if you're targeting enterprise users who might run managed older browsers.

Should I use Radix UI accordion or build my own?

If you're building a design system or a product that needs WCAG 2.2 compliance, use Radix. It handles keyboard navigation (Arrow Up/Down/Home/End per the WAI-ARIA spec), focus management, and correct ARIA roles out of the box. Rolling your own means reimplementing all of that — and probably getting some edge case wrong on the first pass.

How do I animate the chevron icon when the accordion opens?

Add a transition-transform class and conditionally apply rotate-180 based on open state. In Tailwind: className={\transition-transform duration-300 \${open ? 'rotate-180' : ''}\}. This works with SVG icons, emoji arrows, and CSS pseudo-elements alike.

Can I use an accordion inside a modal or drawer?

Yes, but watch for stacking context issues with the glassmorphic variant — backdrop-filter inside a transform parent can behave unexpectedly. Also test keyboard trapping: if your modal manages focus, make sure Tab cycles through accordion triggers without escaping the modal's focus trap.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React UI Components Complete Reference: 60+ Patterns with CodeAccordion in React: Radix vs Custom — Animated Height TransitionsLiquid Button Animation: Blob Hover with SVG filterTailwind Animation Library: 30 Classes for Common Effects