---
name: expandable-card
description: Use when you want "Practical, modern, app-like" - Expands a card to show more information.
---

# Expandable Card

> **Category:** Card / Layout  -  **Personality:** Practical, modern, app-like
>
> **Best use:** Dashboards, FAQ, products
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

An expandable card shows a compact summary and grows **in place** to reveal more detail when the user activates it — no navigation, no modal, the surrounding layout just makes room. It is the workhorse pattern for dashboards, FAQ rows and product/feature lists where you want a scannable overview that can drill down on demand. The hard part is animating an element from a collapsed state to its **intrinsic (content-driven) height** smoothly; this recipe does it with a pure-CSS `grid-template-rows: 0fr → 1fr` transition, so there are no magic `max-height` numbers and no JavaScript height measurement.

## Dependencies / CDN

None - pure CSS for the effect, ~15 lines of vanilla JS for the toggle. The demo only loads display/UI fonts (optional):

```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=Schibsted+Grotesk:wght@500;700;800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
```

## HTML

The toggle is a real `<button>` that owns `aria-expanded` + `aria-controls`; the revealed panel is a labelled region. Note the **three nested wrappers** on the panel — they are all required (see _How it works_):

```html
<article class="card" data-open="false">
  <button class="head" aria-expanded="false" aria-controls="d1">
    <span class="title" id="t1">Mobile App v3.2</span>
    <span class="chev" aria-hidden="true">⌄</span>     <!-- rotates when open -->
  </button>

  <!-- 1) grid wrapper animates its row track  -->
  <div class="reveal" id="d1" role="region" aria-labelledby="t1" inert>
    <!-- 2) overflow:hidden clip  -->
    <div class="reveal-inner">
      <!-- 3) padded content (so padding collapses too)  -->
      <div class="reveal-pad">
        <p>Final polish on onboarding and offline mode…</p>
      </div>
    </div>
  </div>
</article>
```

## CSS

```css
/* THE EFFECT — three lines do the work */
.reveal       { display:grid; grid-template-rows:0fr;
                transition:grid-template-rows .45s cubic-bezier(.22,1,.36,1); }
.card[data-open="true"] .reveal { grid-template-rows:1fr; }   /* 0fr -> 1fr = collapsed -> auto height */
.reveal-inner { overflow:hidden; min-height:0; }              /* min-height:0 lets the grid child shrink to 0 */

/* content fades up as the panel opens (optional polish) */
.reveal-pad { padding:2px 18px 18px; opacity:0; transform:translateY(8px);
              transition:opacity .35s ease, transform .35s ease; }
.card[data-open="true"] .reveal-pad { opacity:1; transform:none; transition-delay:.1s; }

/* chevron affordance */
.chev { transition:transform .45s cubic-bezier(.22,1,.36,1); }
.card[data-open="true"] .chev { transform:rotate(180deg); }

/* grid of cards: align-items:start so expanding one does NOT stretch its row-mates */
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(286px,1fr));
        gap:18px; align-items:start; }

@media (prefers-reduced-motion: reduce){
  .reveal, .reveal-pad, .chev { transition:none !important; }
}
```

## JavaScript

```js
document.querySelectorAll('.head').forEach(function(btn){
  btn.addEventListener('click', function(){
    var card   = btn.closest('.card');
    var reveal = card.querySelector('.reveal');
    var open   = card.getAttribute('data-open') !== 'true';
    card.setAttribute('data-open', open ? 'true' : 'false');
    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
    // keep collapsed content out of the tab order + a11y tree
    open ? reveal.removeAttribute('inert') : reveal.setAttribute('inert','');
  });
});
```

## How it works

- **`grid-template-rows: 0fr → 1fr`** is the trick. A grid with one row of `1fr` resolves to the row's *content height* (i.e. `auto`), and `0fr` resolves to zero — and unlike `height:auto`, **fractional row tracks are interpolatable**, so the browser can animate cleanly between them. No `scrollHeight` measurement, no hard-coded `max-height`.
- **Why three wrappers:** the outer `.reveal` is the grid that animates the track. Its child `.reveal-inner` must be `overflow:hidden; min-height:0` so it can actually be clipped to 0 (grid/flex children default to `min-height:auto` and refuse to shrink below content). The innermost `.reveal-pad` carries the padding, so the padding collapses with the panel instead of leaving a gap at 0fr.
- **The button drives state.** `data-open` on the card is the single source of truth for *styling*; `aria-expanded` on the button is the source of truth for *assistive tech*. The two are flipped together.
- **`align-items:start` on the grid** stops an expanded card from stretching its neighbours in the same row (CSS Grid stretches row items to equal height by default).

## Customization

- **Speed / feel:** tune the `.45s` and the easing. `cubic-bezier(.22,1,.36,1)` is a confident ease-out; use `ease` for something plainer.
- **One-at-a-time (accordion):** before opening a card, loop the others and set their `data-open="false"` / `aria-expanded="false"` / re-add `inert`.
- **Open by default:** render the first card with `data-open="true"`, `aria-expanded="true"` and **no** `inert` (as the demo does) so the effect is visible on load.
- **Older-browser fallback:** if you must support engines without animatable `fr` tracks, swap the technique for a JS `max-height = scrollHeight` toggle; the markup and ARIA stay identical.
- **Chevron:** any rotation works (`rotate(180deg)`, `rotate(90deg)` for a `›`); or swap a `+`/`−`.

## Accessibility & performance

- Use a **real `<button>`** for the toggle (free Enter/Space + focus); never a clickable `<div>`. Pair `aria-expanded` with `aria-controls` pointing at the panel `id`, and label the panel with `role="region"` + `aria-labelledby`.
- **Manage focus on hidden content.** A panel clipped to `0fr` is invisible but its links/buttons are *still tabbable*. The demo toggles the **`inert`** attribute so collapsed content leaves the tab order and accessibility tree. (No-JS / legacy fallback: `visibility:hidden` while collapsed also removes it from tab order.)
- Cheap to run: it animates `grid-template-rows`, `opacity` and `transform` — no layout thrash from JS measuring heights, and nothing repaints continuously. Honour `prefers-reduced-motion` by zeroing the transitions (shown above).

## Gotchas

- **Forgetting `min-height:0` / `overflow:hidden`** on the inner wrapper is the #1 bug — the panel won't fully collapse because grid items refuse to shrink below their content's min-height.
- **Put padding on the inner element, not on `.reveal`.** Padding on the animating grid container can't collapse to zero, so you get a visible sliver when "closed".
- `grid-template-rows` animation needs reasonably current browsers (Chrome/Edge 107+, Firefox 66+, Safari 16+). For anything older, fall back to the `max-height = scrollHeight` approach.
- Don't animate `height:auto` directly — it simply doesn't interpolate; that limitation is the whole reason this `0fr/1fr` pattern exists.
- If cards sit in a grid row together, set `align-items:start` or an expanded card drags its neighbours' height with it.
