Disclosure & OverlaysCollapsible

A single disclosure — the accordion's reveal without the stack.

Preview

3 pinned experiments

craft/sound-palette

The thinking

I keep this component around because most disclosures aren't a stack — they're one panel answering one trigger, and reaching for the accordion there means carrying single-open bookkeeping you never use. So this is the accordion's reveal with everything but the reveal removed: the panel still rides --radix-collapsible-content-height through animate-collapsible-down and animate-collapsible-up, and nothing else came along for the ride. The load-bearing decision is that the two components share one grammar on purpose — a reveal should feel the same whether it's one panel or six, so the difference between them is structural, never a different motion. Everything else this could have owned — a border, a chevron, a sound — I left to composition. It's a clip with a clock.

Interface design

The component's entire visual output is one line of classes: overflow-hidden, the two animation utilities, and motion-reduce:animate-none to drop them. No border, no background, no padding — a disclosure primitive has to wrap anything, so any look it shipped would sooner or later be a look I'd fight. The accordion is the opinionated sibling; this one stays bare. The demo shows what that bareness is for: the trigger borrows the ghost icon Button through asChild, and the rows are plain rounded-md border border-border cards set in font-mono text-sm — none of it mine, all of it composed in. The clip is the interface.

Interaction

The keyframes run height from 0 to --radix-collapsible-content-height, the measured height Radix writes onto the panel, at 0.2s on cubic-bezier(0.23, 0.88, 0.26, 0.92) — ease-swift — under the overflow-hidden clip, so the curve shapes real motion instead of easing through a max-height guess. Both directions get the full 200ms: overlays on this site leave faster than they arrive, but a collapse is content moving, not a surface getting out of the way. The intent reads live in the primitive underneath — the panel moves only on a committed click, never on hover, and when it mounts already open Radix suppresses the animation on first paint. Restored state appears settled, because I want motion narrating a person's act, not a render.

Sound design

There's no audio code here and no data-silent either — the collapsible speaks through delegation. Its trigger renders a native <button>, so the global SoundLayer's one capturing click listener matches it by role and plays the tap as the panel toggles; the sound arrives through semantics, not through anything wired in this file. I also didn't reach for the palette's open and close pair — that pair belongs to surfaces that arrive and leave, and the components that own it mark themselves data-silent so the layer steps aside. A collapsible never leaves; it changes shape. One tap covers both directions.

Empathy

Most of the chaos handling is Radix's, inherited on purpose. The trigger is a real <button> carrying aria-expanded and aria-controls pointed at the panel's id, and a closed panel gets hidden — a screen reader hears the state, and Tab never lands inside collapsed content. Setting disabled on the root disables the trigger and stamps data-disabled across trigger and panel. The focus ring isn't mine either: the trigger composes over a real button, so the demo's ghost Button brings its own focus-visible:ring-1 focus-visible:ring-ring, plus an sr-only “Toggle” label so the icon-only trigger reads as words, not silence. And motion-reduce:animate-none drops the height animation entirely — which is safe, because the height is measured, not guessed: however tall the content runs, the panel opens to its real size.
collapsible.tsx
"use client";

import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";

import { cn } from "@/lib/utils";

const Collapsible = CollapsiblePrimitive.Root;

const CollapsibleTrigger = CollapsiblePrimitive.Trigger;

// same reveal grammar as the accordion: the keyframes ride
// --radix-collapsible-content-height, 200ms ease-swift both ways
const CollapsibleContent = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content> & {
	ref?: React.Ref<React.ComponentRef<typeof CollapsiblePrimitive.Content>>;
}) => (
	<CollapsiblePrimitive.Content
		ref={ref}
		className={cn(
			"overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up motion-reduce:animate-none",
			className
		)}
		{...props}
	/>
);
CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName;

export { Collapsible, CollapsibleTrigger, CollapsibleContent };