This article is published in English.
Crawlable React Accordions: Collapse with CSS Grid, Not Unmounting
Build nested React accordions that keep content in the DOM for indexing, animate height with grid rows, scope one-open-at-a-time per level and reset on close.
Most accordion tutorials render the panel only when it is open. That is fine for a modal, but on a content-heavy page such as a portfolio, documentation hub or FAQ, it means the text inside every closed section does not exist in the document when a crawler renders the page. This guide rebuilds a set of nested collapsible sections so that nothing leaves the DOM, the height animates smoothly without magic numbers, "only one open" works correctly at every nesting level, reopening a section starts fresh, and the click target stops where the header text stops.
The scenario: eleven sections, three levels deep
Consider a personal site with eleven top-level sections that fold away: about, availability, experience, portfolio, education, languages, skills, lab, downloads, location and contact. Several nest further. Contact alone goes three levels deep, with public profiles, publications and job boards each splitting into their own subgroups.
Fully expanded, the page is an overwhelming wall of text. Collapsed, it is easy to scan. Making the sections collapsible is the obvious choice. The question is how.
The pattern nearly every tutorial teaches is conditional rendering:
{isOpen && (
<div className="content">
{children}
</div>
)}
It works, and with a wrapper element it can even animate. But it quietly defeats the purpose of a page that exists to be found.
What conditional mounting really does
{isOpen && ...} does not hide anything. When isOpen is false, React never creates that subtree, so there is nothing in the DOM to hide or show.
For modals and dropdown menus that is exactly the right behavior; a closed dialog has no business sitting in the document. For a content page it is backwards. On a portfolio, the collapsed sections are the substance: years of experience, project write-ups, technology lists, responsibilities and outcomes. Every term a recruiter might search for sits inside something that starts closed.
Google does execute JavaScript, so this is less severe than it once was. But the page is rendered in its initial state. The crawler does not click chevrons. Whatever is unmounted on load is, for indexing purposes, not on the page.
Collapsing with styles instead of rendering
The fix is to treat "collapsed" as a styling concern rather than a rendering decision. The content mounts once and stays mounted; only its visible height changes.
The traditional way to animate height is max-height, which forces you to guess a value larger than your tallest section and accept uneven timing, since the transition runs over the full guessed range rather than the real height. CSS Grid offers a cleaner approach, shown here with Tailwind classes:
<div
className={`grid transition-all duration-300 ${
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`}
>
<div className="overflow-hidden">{children}</div>
</div>
Why the grid-rows trick works
A grid track sized 1fr expands to fit its content's natural height, while a track sized 0fr collapses to zero. Browsers can interpolate between the two, so the transition is smooth and needs no maximum. The inner overflow-hidden wrapper is essential: without it, the content would spill out of the zero-height row instead of being clipped.
The markup is now identical in the open and closed states. Only the computed height and opacity differ, so every word remains in the document and available for indexing.
One caveat: animating grid-template-rows is a relatively recent browser capability. Older engines will simply snap between states, so if you must support them, plan a fallback or accept the lack of animation there. Check current support data against your own traffic.
Keep collapsed content out of the tab order
Keeping content in the DOM has an accessibility side effect worth handling. Links and buttons inside a visually collapsed panel can still receive keyboard focus, and screen readers may still announce them. Adding the inert attribute to the panel while it is closed (or at least aria-hidden plus making inner controls unfocusable) keeps the text in the document for crawlers while taking it out of interaction. Pair the header button with aria-expanded so assistive technology knows the state. The browser also offers hidden="until-found", which keeps content searchable with find-in-page; it is worth evaluating, but its styling and animation story differs from the grid approach.
Scoping "only one open at a time"
With collapsing working, the next requirement is classic accordion behavior: opening one section closes the rest. For a flat list, that is one piece of state holding the open key at the top and an equality check in each item.
Nested sections break this immediately. A single global rule closes the parent the moment you open a child, because the child is just another collapsible and the rule cannot tell the difference. Expand a subgroup such as company job boards, and the section containing it snaps shut beneath the cursor.
The constraint needs a scope: exclusivity applies within a set of siblings under the same parent, not across the page. Each collapsible belongs to its parent's group and also creates a new group for its own children. The group is a small shape holding the open key and its setter:
type CollapseGroup = {
openKey: string | null;
setOpenKey: (key: string | null) => void;
};
That shape is shared through a React context, with null meaning "not inside a group":
const CollapseGroupContext = createContext<CollapseGroup | null>(null);
Each node reads its parent's context to decide whether it is open, then wraps its children in a fresh provider. At three levels of nesting, the state lives in three independent contexts rather than one flat map with composite keys like contact/profiles/boards. That keeps each level simple and makes the depth unlimited without any extra bookkeeping.
Resetting nested state when a parent closes
A subtler usability issue appears only after using the page for a while. Open a section, then a subsection, then a sub-subsection. Close the top-level section and read something else. When you reopen that top section later, it springs back to exactly the three-level state you left behind.
Preserving state sounds considerate, but it is disorienting. Reopening something reads as starting over, and the interface contradicts that expectation. You have forgotten where you were; the UI has not.
The fix is to make closing a node clear everything beneath it. The provider owns its group's open key:
const CollapseGroupProvider = ({ isOpen, children }) => {
const [openKey, setOpenKey] = useState<string | null>(null);
and an effect clears that key whenever the provider's own node closes:
useEffect(() => {
if (!isOpen) setOpenKey(null);
}, [isOpen]); // ...
};
Why the cascade takes care of itself
When a level-one node closes, its provider resets the level-two selection to null. Every level-two node is now closed, which triggers their providers' effects, which clear level three, and so on. The reset propagates to any depth with no explicit tree traversal.
The trade-off is that each level settles in a separate render pass, since effects run after rendering. For a handful of levels this is imperceptible. If you ever find it causing visible flicker in a very deep tree, an alternative is to remount the child provider by changing its key when the parent closes, which discards the nested state in one step. Either way, because the content stays mounted, only the open keys reset; the DOM content itself is never destroyed.
Tightening an oversized click target
A smaller annoyance can take a long time to diagnose. Each header button stretched across the whole row:
className="flex w-full items-center justify-start gap-3 py-1 ..."
So the whole line reacts to clicks, even the blank area after the title, which stretches nearly across the screen. Trying to select text or clicking in the margin would unexpectedly fold or unfold a section.
Switching from w-full to w-fit sizes the button to its content: the emoji, the title and the chevron.
className="flex w-fit items-center justify-start gap-3 py-1 ..."
Choosing w-fit explicitly, rather than just deleting w-full, is deliberate. A <button> with display: flex and automatic width depends on how each browser sizes form controls intrinsically, and being explicit avoids relying on that behavior matching everywhere.
There is no regression on small screens either. A fit-content width is clamped to the available space, never exceeding the container, so a long title on a phone still wraps just as it did before.
Testing the state cascade
Nested state driven by effects is the kind of logic that looks right in review and fails in use. Before shipping, it is worth rendering the provider with React in jsdom and asserting the cases that matter:
- Opening L1, then L2, then L3 leaves all three open.
- Closing L1 leaves all three closed.
- Reopening L1 opens level 1 only, with levels 2 and 3 closed.
- Opening a sibling of L1 leaves the whole L1 branch closed.
- Closing and reopening the entire section leaves everything below it closed.
The third case is the one the reset exists for, and the one most likely to be wrong if the code is trusted on sight. It is also worth adding an assertion that collapsed content is still present in the rendered markup, since that is the property the whole design depends on.
Wrapping up
None of these choices shows up in a screenshot. Visitors will not notice that collapsed text is still in the DOM, that reopening a block gives a clean slate, or that the header stops accepting clicks where the text ends. When it is done well, the only experience is that nothing is irritating, and search engines see the full page.
- Unmount for things that should not exist when closed, such as modals and menus; collapse with CSS for content that should always be part of the page.
- Animate height with
grid-template-rowsbetween0frand1frand anoverflow-hiddenchild instead of guessing amax-height. - Make collapsed panels
inertso hidden content is indexable but not focusable. - Scope accordion state to sibling groups with one context per level, and clear child state when a parent closes.
- Size interactive headers to their content, and test the state transitions rather than eyeballing them.
For related techniques on keeping off-screen content cheap to render while still indexable, see skipping offscreen rendering with content-visibility.