Next.js Collapsible — Animate Without Measuring Height
Use the Drivn Collapsible in Next.js 16 — animate open and closed with CSS grid rows, control state in the App Router, and keep the trigger accessible.
Most expand-and-collapse components animate by measuring the panel height in JavaScript, then setting max-height in pixels. That measuring step needs the DOM, runs on the client, and causes a flash on first paint. The Drivn Collapsible avoids it entirely. The panel is a CSS grid whose single row transitions between 1fr and 0fr — grid-template-rows: open ? '1fr' : '0fr' — so the browser animates the height for you with no measurement, no ref reads, and no layout flash. That one trick is what makes the component fit the App Router cleanly.
The component carries a 'use client' directive because it tracks open state with React.useState, generates stable ids with React.useId, and shares them through a React.createContext. As with every interactive Drivn primitive, importing it draws the client boundary at that import — your page and the surrounding layout stay server components. Next.js ships only the small bundle the Collapsible needs to toggle a boolean and flip a data-state attribute.
This guide installs the Collapsible in a Next.js 16 project, explains why the directive is there, shows how the grid animation renders identically on the server and client, and covers controlled state plus styling the trigger from data-state. Every snippet matches the source the Drivn CLI writes into your repo. For the full prop table see the Collapsible docs.
Install the Collapsible in a Next.js 16 project
Drivn ships no runtime package — the CLI copies the component source into your repository and leaves. From the root of your Next.js 16 project run npx drivn add collapsible. The CLI asks once where components live, defaulting to src/components/ui/, and writes collapsible.tsx there. The file imports React and the local cn class-merge helper and nothing else — no Radix, no animation library, no @radix-ui/react-collapsible. The two Tailwind classes that drive the animation live in a styles object at the top of the file. The CLI reference covers custom directories and batch installs, and the installation guide lists project prerequisites. Once the file lands it is yours to edit; Drivn releases never overwrite it, so you can change the transition duration or the grid trick without fighting an upstream package.
1 # from the root of your Next.js 16 project 2 npx drivn add collapsible
Why the Collapsible needs 'use client'
Open collapsible.tsx and the first line is 'use client'. Three React hooks force the boundary: React.useState(defaultOpen) holds the open state for the uncontrolled case, React.useId() generates the matching triggerId and contentId so the aria-controls/aria-labelledby pair stays stable across hydration, and React.createContext shares those values from the root down to the Trigger and Content. State, ids, and context all need the client, so importing the Collapsible into a Server Component automatically draws the boundary at that import — you do not add 'use client' to your page. This mirrors the Checkbox and differs from the Button, which has no directive and stays server-rendered. Render the Collapsible wherever you like in the App Router and Next.js ships exactly the small client bundle it needs.
1 // collapsible.tsx — the state, id, and context wiring, verbatim 2 const [internalOpen, setInternalOpen] = React.useState(defaultOpen) 3 const open = controlledOpen ?? internalOpen 4 const id = React.useId() 5 const triggerId = `${id}trigger` 6 const contentId = `${id}content`
How the open and close animation works
The Content renders a wrapper <div> with className={styles.panel} — grid transition-[grid-template-rows] duration-200 — and an inline style of gridTemplateRows: open ? '1fr' : '0fr'. Inside it, a second <div className={styles.content}> carries overflow-hidden. When open flips, the single grid row transitions between full content height (1fr) and zero (0fr), and overflow-hidden clips the children during the transition. The browser computes both heights, so there is nothing to measure in JavaScript and nothing to read from a ref. This matters for the App Router: the closed panel renders at zero height on the server with its children present in the HTML, so there is no first-paint flash and no hydration mismatch — the markup the server sends already matches what the client hydrates. Screen readers and crawlers still see the content because it lives in the DOM the whole time, only visually clipped.
1 // collapsible.tsx — the Content panel, verbatim 2 <div 3 id={contentId} 4 role="region" 5 aria-labelledby={triggerId} 6 data-state={open ? 'open' : 'closed'} 7 className={styles.panel} 8 style={{ gridTemplateRows: open ? '1fr' : '0fr' }} 9 > 10 <div className={cn(styles.content, className)}> 11 {children} 12 </div> 13 </div>
Controlled and uncontrolled state in the App Router
By default the Collapsible is uncontrolled — pass defaultOpen and it tracks its own state, which is the right shape for a file tree node or a settings row that nothing else needs to read. When the open state has to drive other UI, pass open and onOpenChange: the component reads const open = controlledOpen ?? internalOpen, so supplying open switches it to controlled mode and the toggle handler calls onOpenChange?.(next) without touching internal state. Controlled mode needs a client component that owns the useState, so keep that directive on the small leaf file holding the state rather than marking the whole page. The Collapsible examples page collects more of these patterns, and the Accordion builds on the same idea when you need a set of panels where only one opens at a time.
1 'use client' 2 import * as React from 'react' 3 import { Collapsible } from '@/components/ui/collapsible' 4 import { ChevronsUpDown } from 'lucide-react' 5 6 export function Dependencies() { 7 const [open, setOpen] = React.useState(false) 8 return ( 9 <Collapsible open={open} onOpenChange={setOpen} className="space-y-2"> 10 <div className="flex items-center justify-between"> 11 <h4 className="text-sm font-semibold">3 pinned dependencies</h4> 12 <Collapsible.Trigger> 13 <ChevronsUpDown className="w-4 h-4" /> 14 </Collapsible.Trigger> 15 </div> 16 <Collapsible.Content className="space-y-2"> 17 <div className="rounded-md border px-4 py-2.5 text-sm">next@16</div> 18 </Collapsible.Content> 19 </Collapsible> 20 ) 21 }
Styling the trigger from data-state
The Trigger renders a real <button> with aria-expanded={open}, aria-controls={contentId}, and data-state={open ? 'open' : 'closed'}. Because the open state lands on the DOM as a data-state attribute, you style the trigger with Tailwind data variants instead of conditional class strings — rotate a chevron with data-[state=open]:rotate-180, or scope it to a named group so several collapsibles in one panel each control their own icon. The button is a native element, so keyboard focus, the Enter and Space keys, and the announced expanded state come from the browser with no ARIA shim to maintain. The role="region" and aria-labelledby on the content tie the panel back to its trigger for screen readers. Re-theme any of it by editing the color tokens the classes reference — the component is in your repo, not a dependency.
1 <Collapsible className="group"> 2 <Collapsible.Trigger className="flex items-center gap-2"> 3 Notifications 4 <ChevronDown className="w-4 h-4 transition-transform group-data-[state=open]:rotate-180" /> 5 </Collapsible.Trigger> 6 <Collapsible.Content className="pt-2 text-sm"> 7 Manage your notification preferences. 8 </Collapsible.Content> 9 </Collapsible>
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
The component file carries the directive itself because it uses useState for open state, useId for the trigger and content ids, and createContext to share them. You never add 'use client' to the page that renders it — importing the Collapsible draws the client boundary at that import. Your surrounding page and layout stay server components, and Next.js ships only the small client bundle the component needs.
The content panel is a CSS grid whose single row transitions between 1fr and 0fr via grid-template-rows, with overflow-hidden on the inner wrapper clipping the children. The browser computes both heights, so there is no JavaScript measurement, no ref read, and no max-height in pixels. That is why there is no first-paint flash in the App Router and the server HTML matches the client.
Yes. Pass open and onOpenChange and the component switches to controlled mode — it reads const open = controlledOpen ?? internalOpen, so your value wins and toggle fires onOpenChange instead of mutating internal state. Controlled usage needs a client component, so keep the directive on the small leaf file that owns the useState rather than marking the whole page client.
Yes. The children always live in the DOM; the closed state just clips them with a zero-height grid row and overflow-hidden. Search crawlers read the full content, and screen readers reach it through the role="region" and aria-labelledby that tie the panel to its trigger. The trigger button also carries aria-expanded and aria-controls so assistive tech announces the open and closed state.

