CSS Expandable Card
A card that expands to reveal more content on click, with no JavaScript and no max-height hack.
A CSS expandable card animates from nothing to exactly the height of its content by transitioning grid-template-rows from 0fr to 1fr. Both of those are values the browser can interpolate, and 1fr resolves to whatever the content actually needs, so there is no pixel guess anywhere in the rule. That is the whole difference between this and the max-height hack it replaces.
The three cards below open independently, driven by a hidden checkbox and a label. Two more builds follow: a radio group where opening one panel closes the rest and the control stays reachable by keyboard, and a version built on the native <details> element, which brings the semantics with it.
Click a header to expand
HTML
<div class="exp-stack">
<div class="exp-card">
<input type="checkbox" id="exp-1" class="exp-toggle">
<label for="exp-1" class="exp-header">
<span class="exp-title">What is CSS nesting?</span>
<span class="exp-icon">+</span>
</label>
<div class="exp-body">
<div class="exp-inner">
<div class="exp-content">CSS nesting lets you write rules inside other rules, just like Sass, but natively in the browser.</div>
</div>
</div>
</div>
<div class="exp-card">
<input type="checkbox" id="exp-2" class="exp-toggle">
<label for="exp-2" class="exp-header">
<span class="exp-title">How does <code>grid-template-rows: 0fr</code> work?</span>
<span class="exp-icon">+</span>
</label>
<div class="exp-body">
<div class="exp-inner">
<div class="exp-content">A grid child with <code>min-height: 0</code> collapses to exactly zero at <code>0fr</code>, then interpolates to its natural height at <code>1fr</code>.</div>
</div>
</div>
</div>
<!-- ...more cards, each with its own checkbox id -->
</div>
CSS
.exp-stack {
display: flex;
flex-direction: column;
gap: .6rem;
width: 100%;
max-width: 420px;
}
.exp-card {
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
overflow: hidden;
}
.exp-toggle { display: none; }
.exp-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .9rem 1.1rem;
cursor: pointer;
user-select: none;
transition: background .15s;
}
.exp-header:hover {
background: #1c1c1e;
}
.exp-title {
font-size: .9rem;
font-weight: 600;
color: #f0f0f0;
}
.exp-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 22px;
height: 22px;
border: 1.5px solid #88888f;
border-radius: 50%;
font-size: 1.1rem;
line-height: 1;
color: #88888f;
transition: transform .35s ease, border-color .2s, color .2s;
}
/* the height animation itself */
.exp-body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .35s ease;
}
.exp-inner {
overflow: hidden;
}
.exp-content {
padding: .9rem 1.1rem;
border-top: 1px solid #2a2a2d;
font-size: .85rem;
line-height: 1.75;
color: #88888f;
}
.exp-toggle:checked ~ .exp-header .exp-icon {
transform: rotate(45deg);
border-color: #b8ff57;
color: #b8ff57;
}
.exp-toggle:checked ~ .exp-body {
grid-template-rows: 1fr;
}
.exp-stack code {
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .8em;
color: #88888f;
background: #1c1c1e;
padding: .15em .4em;
border-radius: 4px;
border: 1px solid #2a2a2d;
}
Other ways to build it
A radio accordion that a keyboard can reach
Two changes from the version above. Radio buttons sharing a name mean the browser closes one panel when another opens, so the accordion behavior costs no extra CSS. And the input is visually hidden rather than display: none, which keeps it in the accessibility tree and focusable, with the focus ring drawn on the label since the input itself has no visible box. Tab into the group and the arrow keys move between panels, because that is what a radio group does. The last radio has no panel of its own and exists only so everything can be closed again, which a radio group otherwise makes impossible.
HTML
<div class="exp-card">
<input type="radio" name="acc" id="acc-1" class="ec-toggle">
<label for="acc-1" class="ec-header">
<span class="exp-title">Why use radio buttons here?</span>
<span class="ec-icon">+</span>
</label>
<div class="ec-body">
<div class="ec-inner">
<div class="exp-content">Radios in one group are mutually exclusive.</div>
</div>
</div>
</div>
CSS
/* visually hidden, NOT display: none. A display: none input leaves the
accessibility tree and can never be focused. */
.ec-toggle {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.ec-body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .35s ease;
}
/* this is what lets 0fr collapse: a grid item's automatic minimum
size is its content unless overflow is something other than visible */
.ec-inner {
overflow: hidden;
}
.ec-toggle:checked ~ .ec-body {
grid-template-rows: 1fr;
}
.ec-toggle:checked ~ .ec-header .ec-icon {
transform: rotate(45deg);
border-color: #b8ff57;
color: #b8ff57;
}
/* the input has no visible box, so the ring goes on the label */
.ec-toggle:focus-visible ~ .ec-header {
outline: 2px solid #b8ff57;
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.ec-body { transition: none; }
.ec-icon { transition: none; }
}
The native details element
The browser already has a disclosure widget. <details> with a <summary> is keyboard operable out of the box, announced as a disclosure rather than as a checkbox, and it hides its own content from find in page until it is opened, which none of the checkbox versions do. The cost is the animation: details switches between open and closed rather than transitioning, so the panel appears instantly. Styling is straightforward once list-style: none removes the default marker, and the [open] attribute selector handles the icon. For an FAQ this is almost always the right trade, and it is why the questions further down this page are built from it.
HTML
<details class="ec-det">
<summary class="ec-sum">
<span class="exp-title">Is details keyboard accessible?</span>
<span class="ec-icon">+</span>
</summary>
<div class="exp-content">Yes, with no attributes added.</div>
</details>
CSS
.ec-det {
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
overflow: hidden;
}
.ec-sum {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .9rem 1.1rem;
cursor: pointer;
/* removes the default triangle in every engine */
list-style: none;
}
.ec-sum::-webkit-details-marker {
display: none;
}
.ec-det[open] .ec-icon {
transform: rotate(45deg);
border-color: #b8ff57;
color: #b8ff57;
}
How it works
Setting grid-template-rows: 0fr on a one row grid collapses that row to zero, and transitioning to 1fr lets the browser work out the natural content height on its own with no fixed pixel value involved. The overflow: hidden on the inner element does two jobs: it clips the content while the row is shorter than the content, and it is what allows the row to collapse to zero in the first place.
The card body is a grid with exactly one row track. grid-template-rows: 0fr gives that track zero share of the free space and 1fr gives it everything, which resolves to the content's own height. Both are computed lengths, so the browser can interpolate between them, and the transition ends at the right height whether the panel holds one line or twenty. Nothing else in CSS animates to an unknown height without a script measuring it first.
The overflow: hidden on the inner wrapper is load bearing in a way that is easy to miss. A grid item's min-height defaults to auto, which in the block direction means it refuses to shrink below its own content, so 0fr on its own would collapse to the content height and the transition would do nothing visible. An item whose overflow is anything other than visible has an automatic minimum size of zero instead. Swap that one declaration for overflow: visible and the panel sits permanently open. It is worth writing a comment next to it, because it reads like a purely cosmetic clip.
The max-height approach this replaces has two failures, and they pull in opposite directions. Guess the maximum too high, say max-height: 1000px for content that is 120px tall, and the transition spends most of its duration animating empty space, so the panel snaps open and then appears to hang. Guess too low and the content is silently cut off with no error and nothing in the inspector to point at it. There is no correct guess, because the content is variable, which is exactly the problem 1fr does not have.
State is held by a checkbox. <input type="checkbox"> followed by a <label for> gives a control that toggles on click and on tap with no script, and the general sibling combinator ~ reaches from the checked input to the body further down the card. The catch, and it applies to the demo above, is display: none on the input. A hidden input is removed from the accessibility tree and cannot be focused, so a keyboard user cannot open the card at all. The first variant below replaces that with a visually hidden input that stays focusable and puts the focus ring on the label.
The icon is one character and one transform. A + rotated 45 degrees is a ×, so the open and closed states need no second glyph, no icon font and no SVG swap. transition: transform .35s ease on the icon and grid-template-rows .35s ease on the body means the two move together, which reads as one action rather than two.
CSS properties used
grid-template-rows0frto1fron a one row grid. The only property that animates to an unknown content height without measuring it first.overflowhiddenon the grid item. It clips the content during the transition and, more importantly, sets the item's automatic minimum size to zero so it can collapse at all.transition- On
grid-template-rowsfor the panel and ontransformfor the icon, at the same duration, so the two read as a single movement. :checked- Matches the hidden input when it is on. Everything about the open state hangs off this one pseudo-class.
~- The general sibling combinator, reaching from the input to the body and the icon further along in the card. They have to be siblings for it to work.
transformrotate(45deg)turns a plus into a cross, which avoids swapping the icon for a second character.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
CSS Grid | 57 | 52 | 10.1 | 16 |
CSS Grid animation | 107 | 66 | 16 | 107 |
transition | 4 | 5 | 5.1 | 12 |
details | 12 | 49 | 6 | 79 |
:focus-visible | 86 | 4 | 15.4 | 86 |
Figures come from Can I Use. The row that matters is CSS Grid animation, which is what makes a grid-template-rows transition run at all. Chrome and Edge only got it in version 107 and Safari in 16, which is much more recent than grid layout itself. In an older browser the panel still opens and closes correctly, it simply jumps rather than sliding, so the technique degrades to an instant toggle rather than breaking. That is a better fallback than the max-height version has.
Accessibility notes
display: none on the toggle input makes the card unusable with a keyboard. The input is removed from the accessibility tree, so it cannot receive focus, and a label alone is not a focusable control. Use the visually hidden pattern instead: position the input absolutely at one pixel with opacity: 0, keep it in the tree, and draw the focus ring on the label with :focus-visible. The first variant below does this, and it is a change worth making everywhere this pattern appears.
A checkbox and a label do not announce themselves as an expandable region. A screen reader hears a checkbox, which is close enough that most readers cope, but the honest markup is a <button> with aria-expanded, and that needs script. The <details> element in the second variant is the middle ground: it is announced as a disclosure, it works with a keyboard by default, and it needs no JavaScript.
Content in a collapsed panel is still in the accessibility tree unless something removes it. overflow: hidden only clips painting, so a screen reader will read out the text of a card that looks closed, and a browser find will jump to it. <details> handles this properly, hiding its content from search and from assistive technology until it is opened.
Respect prefers-reduced-motion by removing the transition rather than the toggle. The card should still open, it should simply arrive at its open state immediately. Both variants below include that block.
What you can build with it
- FAQ sections. The most common use, and the one where
<details>is the right choice because search engines and screen readers both understand it. - Filter panels. Groups of options in a sidebar, collapsed by default so the list of categories stays scannable.
- Settings and preferences. Advanced options folded away, with the radio variant so only one group is open at a time and the page never grows unpredictably.
- Order and invoice summaries. A line total that expands into the itemised breakdown, where the content height genuinely varies per order.
- Documentation and changelogs. Long release notes collapsed to headings, expanded on demand, with each entry independently toggleable.
Mistakes worth avoiding
- Using
display: noneon the toggle input. The card becomes impossible to open with a keyboard, and nothing about the visual design hints that anything is wrong. - Removing
overflow: hiddenfrom the inner wrapper. The grid item's automatic minimum size goes back to its content height, so0frno longer collapses and the panel sits open permanently. - Putting the padding on the collapsing element. Padding is not part of the row track, so the panel keeps that padding at
0frand the closed card shows a strip of empty space. Move it to a wrapper inside. - Reaching for
max-heightinstead. There is no right maximum: too high wastes most of the transition on empty space, too low clips the content with no warning. - Reusing the same
idfor several cards. Every checkbox needs its own, and a duplicated one silently attaches every label to the first input, so clicking any header opens the same card.
Frequently asked questions
How do you animate height to auto in CSS?
grid-template-rows from 0fr to 1fr. The 1fr resolves to the content's natural height, so the animation ends in the right place without measuring anything. The grid item needs overflow: hidden for the collapse to work.Why does my grid-template-rows transition do nothing?
overflow: hidden. A grid item's automatic minimum size is its content, so it refuses to shrink to zero. Setting overflow to anything other than visible drops that minimum to zero and the collapse works.Is this better than a max-height transition?
Can I use the details element instead?
How do I make only one card open at a time?
name. Selecting one deselects the rest, so the browser enforces the accordion behavior for you. The tradeoff is that a radio cannot be unchecked by clicking it again, so add an extra radio elsewhere if closing everything has to be possible.