Disclosure & OverlaysCollapsible
A single disclosure — the accordion's reveal without the stack.
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."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 };