The classic way to animate an element open is max-height: 0 → max-height: 500px. It works, but it has a problem: the browser has no idea how tall the content actually is.
The problem with max-height
It animates from 0 to 500px regardless of whether the real height is 40px or 490px. The result is a noticeably uneven easing — the element snaps open fast at first, then slows to nothing as it approaches a ceiling that was never real.
You cannot animate to max-height: auto. CSS transitions only work between two concrete values.
The grid trick solves this.
What grid-template-columns and grid-template-rows are
grid-template-columns and grid-template-rows define the track sizes of a grid container — how wide each column is, how tall each row is.
grid-template-columns: 200px 1fr 100px;
/* three columns: fixed, flexible, fixed */
grid-template-rows: auto 1fr;
/* two rows: shrink-wrap content, then fill remaining space */The fr unit means "fraction of available space." The total is the sum of all fr values across every track in that dimension — not declared anywhere, just derived.
grid-template-columns: 1fr; /* 1/1 = 100% */
grid-template-columns: 1fr 1fr; /* 1/2 | 1/2 */
grid-template-columns: 2fr 1fr; /* 2/3 | 1/3 */
grid-template-columns: 1fr 2fr 1fr; /* 1/4 | 2/4 | 1/4 */1fr does not inherently mean "all space." It means one unit out of however many fr units exist across the whole grid. When there is only one track, the sum is 1, so that track takes everything. Any non-zero fr value would behave identically in a single-track grid — the ratio is what matters, not the number.
When to use auto instead of fr
fr units only divide remaining space — after auto, fixed-size, and min-content tracks have already claimed their share. So auto sizes to content first, then fr splits whatever is left.
grid-template-columns: auto 1fr;
/* col 1: exactly as wide as its content
col 2: everything else */
grid-template-columns: 1fr 1fr;
/* col 1: half the total space
col 2: half the total space — content size is irrelevant */The difference is clear with any icon-and-label layout:
auto 1fr | 1fr 1fr |
|---|---|
| 🌸 Spring | 🌸 Spring |
| ☀️ Summer | ☀️ Summer |
| 🍂 Autumn | 🍂 Autumn |
| ❄️ Winter | ❄️ Winter |
With auto 1fr, the icon column shrinks to the glyph width and the label takes the rest. With 1fr 1fr, both columns split space equally and the icon sits in a column far wider than a single character. Use auto when a track should hug its content; use fr when it should fill proportional space.
In my project
The season toggle uses auto 1fr for exactly this reason — the emoji column stays tight, and the season name takes the remaining width.
The interesting unit here is 0fr — a track that takes zero space.
A child element inside a 0fr track is not hidden. It still exists in the DOM, it still has its natural dimensions, it is just constrained to a box of zero size. Pair it with overflow: hidden and it disappears completely.
The trick
Animating from grid-template-columns: 0fr to grid-template-columns: 1fr causes the track to smoothly expand from zero to its natural content width — whatever that width happens to be. No hardcoded ceiling. The browser measures the content and that is the target.
.wrapper {
display: grid;
grid-template-columns: 0fr;
transition: grid-template-columns 300ms ease-in-out;
}
.wrapper.open {
grid-template-columns: 1fr;
}
.inner {
overflow: hidden; /* clips the child while the track is smaller than its content */
}In Tailwind, this pattern looks like:
<div
className={`grid transition-[grid-template-columns] duration-300 ease-in-out ${
open ? 'grid-cols-[1fr]' : 'grid-cols-[0fr]'
}`}
>
<div className="overflow-hidden">
{/* content */}
</div>
</div>The same trick works on rows — swap grid-template-columns for grid-template-rows and grid-cols-[0fr] for grid-rows-[0fr].
.wrapper { display: grid; grid-template-rows: 0fr; } .inner { overflow: hidden; }
What flex-col does
flex-col sets flex-direction: column on a flex container. Without it, flex items lay out in a row — side by side horizontally. With it, they stack vertically.
.container { display: flex; flex-direction: row; gap: 8px; }
In my code
The season toggle uses flex-col on mobile (pill above button) and flex-row on desktop (pill to the left).
<div className="flex flex-col items-end sm:flex-row sm:items-center">
{/* pill */}
{/* circle button */}
</div>items-end aligns the shorter pill to the right edge of the column — without it, it stretches to full width. items-center vertically centres both elements in the row.
Why mb-2 pushes the pill upward, not downward
In a flex-col layout, mb-2 adds space below the first item. If the container is anchored to the bottom of the viewport, the second item can't move — so the first item is what shifts, and it shifts upward.
In my project
The DOM order is pill first, button second. The container is fixed bottom-6. mb-2 on the pill adds 8px between the pill and the button. The button stays anchored; the pill pushes upward.
Why mr-2 pushes the pill leftward, not rightward
In a flex-row layout, mr-2 adds space to the right of the first item. If the container is anchored to the right of the viewport, the second item stays put — so the first item can only expand leftward.
In my project
On desktop, the DOM order is still pill first, button second — now side by side. The container is fixed right-6. mr-2 on the pill adds 8px between the pill's right edge and the button's left edge. The button stays anchored; the pill expands left.
/* anchored to bottom-right corner */ .container { position: fixed; bottom: 24px; /* bottom-6 */ right: 24px; /* right-6 */ display: flex; flex-direction: column; align-items: flex-end; } /* pill is first child */ .pill { margin-bottom: 8px; /* mb-2 */ /* button can't move down → pill shifts up */ }
Why 2 and not 4
Gap between related elements should be proportional to the elements' size — tight enough that they read as a unit, loose enough to breathe.
In my project
The button is w-14 h-14 — 56px. mb-4/mr-4 (16px) would be more than a quarter of the button's diameter. At that distance the pill and button start to feel like separate things. mb-2/mr-2 (8px) keeps them reading as one widget.
The gap is intentionally tight. It says: this pill belongs to that button.