---
name: accordion-cards
description: Use when you want "Professional, organized, practical" - Expands one card or section at a time to reduce clutter.
---

# Accordion Cards

> **Category:** Card / Layout  -  **Personality:** Professional, organized, practical
>
> **Best use:** FAQ, settings, documentation
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 1/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 1/5

## What it is

An accordion is a vertical stack of header rows where clicking a header expands its panel and collapses the others, so only **one section is open at a time**. It lets a page advertise many topics in a compact list while revealing detail strictly on demand — ideal for FAQs, settings panes and documentation. The whole thing is just a `<button>` (wrapped in a heading) plus a collapsible region, with the open/closed state mirrored onto `aria-expanded` so assistive tech stays in sync. The demo is a "Cadence" help-center FAQ with a live search filter on top.

## Dependencies / CDN

**None.** The expand/collapse is pure CSS; the one-open-at-a-time logic and keyboard nav are vanilla JS. Fonts are optional dressing:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;700&family=Manrope:wght@400;500;600;700&family=Spectral:ital,wght@0,600;1,500&display=swap" rel="stylesheet">
```

## HTML

Each item = a heading-wrapped trigger button + a panel region. Wire them together with `id`/`aria-controls`/`aria-labelledby`. Mark the one that should start open with `data-open` + `aria-expanded="true"`; repeat the block per question.

```html
<div class="ac-list">
  <div class="ac-item" data-open>
    <h3 class="ac-h">
      <button class="ac-trigger" id="ac-b1" type="button" aria-expanded="true" aria-controls="ac-p1">
        <span class="ac-index">01</span>
        <span class="ac-q">How does billing work on annual plans?</span>
        <span class="ac-icon" aria-hidden="true"></span>
      </button>
    </h3>
    <div class="ac-panel" id="ac-p1" role="region" aria-labelledby="ac-b1">
      <div class="ac-panel-inner">
        <p class="ac-a">Annual plans are billed once, upfront&hellip; <strong>Settings &rarr; Billing</strong>.</p>
      </div>
    </div>
  </div>
  <!-- repeat .ac-item (aria-expanded="false", no data-open) for items 02, 03 … -->
</div>
```

## CSS

The only lines that *make* the effect are the three `.ac-panel` rules; everything else is styling.

```css
/* === THE EFFECT: animate the grid track from 0fr to 1fr === */
.ac-panel{
  display:grid;
  grid-template-rows:0fr;                       /* collapsed */
  transition:grid-template-rows .45s cubic-bezier(.22,.61,.36,1);
}
.ac-item[data-open] .ac-panel{ grid-template-rows:1fr; }  /* expanded to content height */
.ac-panel-inner{ overflow:hidden; min-height:0; }          /* clips while the track is < content */

/* answer fades/slides in as the row opens */
.ac-a{margin:0; padding:0 22px 22px 68px; color:#565b6e; line-height:1.66;
  opacity:0; transform:translateY(-5px); transition:opacity .32s ease .04s, transform .32s ease .04s}
.ac-item[data-open] .ac-a{ opacity:1; transform:none; }

/* item shell + open emphasis */
.ac-list{display:flex; flex-direction:column; gap:11px}
.ac-item{background:#fff; border:1px solid rgba(27,29,39,.09); border-radius:16px; overflow:hidden;
  transition:border-color .25s, box-shadow .25s, background .25s}
.ac-item[data-open]{border-color:rgba(91,78,224,.32);
  box-shadow:0 18px 40px -22px rgba(63,51,181,.5)}

.ac-trigger{width:100%; display:flex; align-items:center; gap:14px; padding:18px 20px;
  font:inherit; text-align:left; color:inherit; background:none; border:0; cursor:pointer}
.ac-trigger:focus-visible{outline:2px solid #5b4ee0; outline-offset:-2px}
.ac-index{flex:0 0 34px; font-family:'JetBrains Mono',monospace; color:#565b6e}
.ac-q{flex:1; font-weight:600; font-size:16.5px}

/* + that morphs to - (rotate the vertical bar onto the horizontal one) */
.ac-icon{flex:0 0 auto; position:relative; width:34px; height:34px; border-radius:10px;
  border:1px solid rgba(27,29,39,.15); transition:background .25s, border-color .25s}
.ac-item[data-open] .ac-icon{background:#5b4ee0; border-color:#5b4ee0}
.ac-icon::before,.ac-icon::after{content:""; position:absolute; left:50%; top:50%; width:13px; height:2px;
  border-radius:2px; background:#565b6e; transform:translate(-50%,-50%);
  transition:transform .3s cubic-bezier(.4,0,.2,1), background .25s}
.ac-item[data-open] .ac-icon::before,.ac-item[data-open] .ac-icon::after{background:#fff}
.ac-icon::after{transform:translate(-50%,-50%) rotate(90deg)}            /* vertical bar = "+" */
.ac-item[data-open] .ac-icon::after{transform:translate(-50%,-50%) rotate(0deg)}  /* flat = "-" */

@media (prefers-reduced-motion:reduce){
  .ac-panel,.ac-a,.ac-icon::before,.ac-icon::after,.ac-item{transition:none}
}
```

## JavaScript

CSS drives the open animation; JS only flips `data-open` + `aria-expanded` and enforces one-open-at-a-time. (The demo also adds an optional search filter — omitted here as it isn't part of the effect.)

```js
(function(){
  var items=[].slice.call(document.querySelectorAll('.ac-item'));
  var triggers=items.map(function(it){ return it.querySelector('.ac-trigger'); });

  function setOpen(item, open){
    item.toggleAttribute('data-open', open);
    item.querySelector('.ac-trigger').setAttribute('aria-expanded', open ? 'true' : 'false');
  }
  function openOnly(item){ items.forEach(function(it){ setOpen(it, it===item); }); }

  triggers.forEach(function(btn, i){
    btn.addEventListener('click', function(){
      var item=items[i];
      if(item.hasAttribute('data-open')) setOpen(item, false);  // collapse current
      else openOnly(item);                                      // open this, close rest
    });
    // WAI-ARIA Accordion keyboard pattern: move focus between headers
    btn.addEventListener('keydown', function(e){
      var n=triggers.length, p=i, go;
      if(e.key==='ArrowDown') go=triggers[(p+1)%n];
      else if(e.key==='ArrowUp') go=triggers[(p-1+n)%n];
      else if(e.key==='Home') go=triggers[0];
      else if(e.key==='End') go=triggers[n-1];
      if(go){ e.preventDefault(); go.focus(); }
    });
  });
})();
```

## How it works

- **`grid-template-rows: 0fr → 1fr`** is the trick. You can't transition `height: auto`, and `max-height` hacks force you to guess a number (too small = clipped, too big = laggy easing). A grid row of `1fr` resolves to *exactly* the content's natural height, and `fr` values are interpolatable, so the panel animates to the right height every time — no measuring, no magic numbers.
- The panel is the **grid container**; its single child `.ac-panel-inner` carries `overflow:hidden` + `min-height:0` so content is clipped smoothly while the track shrinks below content size.
- All padding lives on the **inner** child, never on `.ac-panel`. If you pad the panel itself, that padding stays visible at the collapsed `0fr` state.
- State is a single source of truth: the `data-open` attribute on `.ac-item` drives every visual (panel height, icon, accent, answer fade) via CSS, while JS keeps `aria-expanded` in lockstep for assistive tech.
- **One at a time** is just `openOnly()` setting every item's open state to `it === clicked`. Clicking the already-open row collapses it (you may pin one open instead — see below).

## Customization

- **Speed / feel:** tune the `.45s` and the easing on `.ac-panel`. A snappier `.3s cubic-bezier(.4,0,.2,1)` suits dense settings lists; slower suits marketing FAQs.
- **Allow multiple open:** drop `openOnly` and just toggle the clicked item: `setOpen(item, !item.hasAttribute('data-open'))`.
- **Always keep one open:** in the click handler, skip the collapse branch so a header can only ever open (never close itself).
- **Indicator:** swap the `+`/`-` for a chevron — replace the two pseudo-bars with one SVG and `transform:rotate(180deg)` on `[data-open]`.
- **Palette:** the demo themes off one accent (`#5b4ee0`); recolour the open border, glow, icon fill and `.ac-index`. A light "paper" card stack reads as premium docs; a dark stack reads as an app pane.
- **Default open:** add `data-open` + `aria-expanded="true"` to whichever item should greet the user.

## Accessibility & performance

- Each trigger is a **real `<button>` wrapped in a heading** (`<h3>`) of the correct document level — screen-reader users get a navigable heading list, and Enter/Space toggle for free.
- `aria-expanded` on the button + `aria-controls` to the panel, and `role="region"` + `aria-labelledby` on the panel, announce the relationship and the open/closed state.
- Keyboard support follows the **WAI-ARIA Accordion pattern**: Tab reaches each header, ArrowUp/ArrowDown move between headers, Home/End jump to first/last.
- `:focus-visible` gives a clear inset focus ring without punishing mouse users.
- Honour `prefers-reduced-motion` — the panel snaps open instead of animating (also caught by the catalog's global reduced-motion reset).
- **Cheap, but not free:** animating a grid track relayouts that one panel each frame. For short text answers it's imperceptible; for very tall/complex panels, animate a `transform` on the inner instead, or only animate the items in view.

## Gotchas

- **The collapsed child must clip itself.** `.ac-panel-inner` needs both `overflow:hidden` and `min-height:0`; without `min-height:0` the grid item refuses to shrink under its content and you get no animation.
- **Browser support:** interpolating `grid-template-rows` between `0fr`/`1fr` works in current Chrome/Edge (107+), Safari (16+) and Firefox. For older engines it simply snaps (still functional) — or fall back to a `max-height` transition.
- **Hidden focus traps:** at `0fr` the content is *clipped, not removed* — links/inputs inside a closed panel are still tabbable. Keep panels text-only (the demo does), or add `inert`/`hidden` to closed panels and remove it on open.
- **Don't nest the heading inside the button** (or vice-versa incorrectly): the pattern is `<h3><button>…</button></h3>`. Putting the button outside a heading loses the heading semantics; putting a heading inside the button is invalid.
- **`toggleAttribute(name, force)`** needs the boolean second argument to behave as set/unset — passing only the name flips it, which breaks `setOpen`.
