GodUIGodUI
129Follow on X

Anatomy of the GodUI Collapsible

How the GodUI Collapsible gets the look of a height animation without a wrapper of its own, and why the content that rides its edge lives outside it.

01/05A window with no frame
tsx
// The box slides out from under the row above…box.animate([{ translate: `0 ${from}px` }, { translate: `0 ${to}px` }],timing,);// …while each of its children slides back by exactly as much.for (const child of box.children)child.animate(  [{ translate: `0 ${-from}px` }, { translate: `0 ${-to}px` }],  timing,);
01

A window with no frame

The trick is the Accordion's: the panel's height snaps open in one step, its box starts tucked up and slides down, and the content inside slides up by the same amount, so it never moves. The box clips, so its bottom edge uncovers the content like a height animation would.

An Accordion owns a wrapper inside its panel to counter-move. A Collapsible doesn't: shadcn's CollapsibleContent is the box you style, often a flex column. Adding a wrapper would break that layout, so each direct child of the box gets the counter-move instead, on the same clock. The box clips only while it sweeps (overflow: clip, which adds no formatting context), so the content's focus rings and shadows aren't cut off at rest.

02/05The room, not the height
tsx
const room = root.offsetHeight; // panel in the flowbox.style.position = "absolute";const without = root.offsetHeight; // panel out of itbox.style.position = "";const distance = room - without; // its height + the gap before it
02

The room, not the height

What follows the Collapsible is FLIPped: measured before and after the snap, put back with a translate, then played to rest on the same spring and duration as the edge. For the two to stay glued, they must travel the same distance. In a flex flex-col gap-2 root the panel takes its height plus the gap before it, so the edge travels the room the panel takes in the root, not its height.

The followers are the Collapsible's later siblings in its parent and the root's own children after the panel. A MutationObserver on the root's data-state fires in the same task as the click, and flushSync runs the FLIP before the browser paints the snapped layout, so no frame shows the jump. When a stage centers the Collapsible's parent, the parent moves by half the new height: it glides there too, from where it was drawn at the click.

03/05Leaving the flow
tsx
const was = anchor.getBoundingClientRect();const rect = box.getBoundingClientRect();box.style.position = "absolute";box.style.top = "0px";const origin = box.getBoundingClientRect();const is = anchor.getBoundingClientRect(); // a centering parent moved itbox.style.top = `${rect.top - was.top - (origin.top - is.top)}px`;
03

Leaving the flow

Closing can't shrink first and let the content below follow; that's two steps. The moment it closes, the panel leaves the flow: it goes absolute, pinned where it's drawn at its measured width, and the layout collapses at once. The content below starts rising and the edge sweeps up with it. A near-silent hold keyframe keeps Radix from hiding the panel until the sweep is done.

The root is relative so the pinned panel has a containing block that never changes mid-sweep (a parent's glide would otherwise become one). Offsets are measured against it before and after, because leaving the flow can move it: in a centered stage, the shorter parent re-centers in that same step.

04/05Nesting
tsx
// A nested root's glider moves by its own displacement minus the// nearest outer root's: the outer one is put back (glided or FLIPped)// and carries it that far.let dy = before.own.top - now.top;if (outer) dy -= before.outer.top - outerNow.top;
04

Nesting

Every Collapsible and Accordion watches the open states beneath it, so an inner panel's change also glides the outer one's followers, on one clock. An inner one that just rides along with its outer one mustn't glide again, or it would move twice as far. One that its own stage re-centers still has to glide by that move. So a nested one glides by its own displacement minus the nearest outer root's. Whatever moved the outer root (its own glide or its parent's FLIP) also puts it back. That leaves zero in the first case and the relative move in the second.

A FLIP group measures where things sit before a change. An inner Collapsible that mounts while the outer panel is still sweeping would measure its content mid-sweep, so the baseline waits until the sweep is done.

05/05Result

@peduarte starred 3 repositories

@radix-ui/primitives

Everything after the collapsible slides into place.

05

The result

Toggle the list, and click again mid-sweep to reverse it.

What's animated

shadcn's collapsible doesn't animate. GodUI's runs the same engine as Accordion.

InteractionMechanismPropertiesEasingDuration
Openheight snaps; the panel box slides from -room to rest while each of its children slides the other way (the clip edge sweeps down); the content after it FLIPs on the same clock; the children fade in a beat aparttranslate, opacityease-spring-smooth; fades ease-out-expo260ms
Closethe panel leaves the flow at once (absolute, where it's drawn); the edge sweeps back up while the content after it rises; the children fade out ahead of the edgetranslate, opacityease-spring-smooth; fades ease-out-expo150ms
Parent movesif a stage centers the Collapsible's parent, the parent glides to its new spot on the same clocktranslateease-spring-smoothas above

What rides the edge: the Collapsible's later siblings in its parent, and the root's own children after the panel. A Collapsible nested in another Collapsible or an Accordion moves the outer one's content too, on the same clock. CollapsibleContent with asChild works the same way: the child it renders is the box and its children hold still.

The box clips only while its edge sweeps (data-sweeping), so focus rings and shadows of the content aren't cut off at rest. Two kinds of panel can't be split into a box and still children. One has loose text, or an inline or display: contents child, directly inside it; none of those can move on their own. The other has a box that paints a background, border or shadow, which would slide over the trigger. Those fade in and out instead, and the content after them still glides. Wrap loose text in a block element to get the sweep.

The root gets ease-spring-smooth, the clock's curve. Add your own ease-* class to the root to change it.

Why GPU-only

The panel box, its children, the content after it and a centered parent animate translate; the children animate opacity. Layout runs once per click. A panel that can't be split into a box and still children (loose text, or a box that paints its own background, border or shadow) fades with filter: opacity(), because its opacity belongs to the hold keyframe and Chrome won't composite two opacity animations on one element.

Reduced motion

Nothing moves: the height and the content after it change in one step, and the panel's content only fades in or out, over --godui-duration-base opening and --godui-duration-fast closing (both 150ms under reduced motion).

Replacing shadcn

npx shadcn add @godui/collapsible overwrites components/ui/collapsible.tsx. Exports, props and data-slot attributes match shadcn/ui new-york-v4, so existing imports keep working. It also installs godui-motion, which includes the useFlipGroup and useReveal hooks it uses.

One layout difference: the Collapsible root is relative, so a closing panel has a fixed anchor when it leaves the flow. Absolutely positioned descendants that anchored to an outer ancestor now anchor to the root. With asChild, the root adds no class (Radix's Slot concatenates classes, so a child that is absolute, fixed or sticky would get both); it sets position: relative inline only if the child is otherwise static.