Disclosure & OverlaysAccordion
Stacked disclosure on the Radix accordion primitive — real height, full-row triggers.
The thinking
I reach for the disclosure pattern whenever a page has more to say than it should show at rest, and I'd rather not hand-roll the focus and keyboard wiring every time — so this sits on
@radix-ui/react-accordion and inherits the WAI-ARIA behavior for free. The one decision the whole thing turns on is animating the real measured height: the keyframes ride --radix-accordion-content-height under an overflow-hidden panel, so the content is revealed by a clip that tracks its actual size rather than a max-height guess that eases through empty space and lands with a stutter. The detail I care about is that opening reads as a single act and not a coincidence: the panel and the CaretDown share one 200ms ease-swift clock, and both halves drop their motion under motion-reduce together rather than separately. The rest is restraint — a thin wrapper that adds height and a turning chevron and otherwise stays out of the primitive's way.Interface design
There is one look and no variants — the root is the Radix primitive re-exported untouched, and every visual decision here fits in a few class strings. Rows are separated, not boxed: each item draws a single
border-b border-border hairline with no background, so the accordion reads as lines in the page rather than a stack of cards. The trigger sets hierarchy with weight alone, text-sm font-medium for the question against plain text-sm for the answer, with py-4 giving the row its height and text-left keeping wrapped questions ragged instead of centered. The caret sits at size-4 shrink-0 text-muted-foreground — muted because it's an affordance, not content, and shrink-0 so a long question squeezes its own lines, never the icon. The answer's padding lives on an inner div, pb-4 pt-0, so the animated panel clips clean at height zero and the trigger's own bottom padding carries the space above the text.Interaction
Open is one act with two readouts. The panel animates its real height — the
animate-accordion-down keyframes run from zero to --radix-accordion-content-height, 200ms on cubic-bezier(0.23, 0.88, 0.26, 0.92), the house swift curve — and the CaretDown flips rotate-180 on transition-transform duration-200 ease-swift, the same clock, so the chevron and the reveal finish together instead of merely near each other. The other decision is where intent lands: the trigger is the whole row, flex-1 stretching the button across the full width, because the heading you were just reading is the thing you mean to press — asking you to aim at a size-4 caret would put a targeting task between you and the answer. I try to keep the layer between intent and software thin. A full-width row is about as thin as it gets.Sound design
The trigger renders a native
<button>, so it speaks through the global sound layer — one capturing click listener that plays the tap for anything carrying an interactive role. This file ships zero audio code and no data-silent marker; the role is the wiring. The palette does hold an open/close pair, but I don't spend it here: a disclosure toggles in place and the panel's motion already tells you which way it went, so both directions get the same tap. Disabled triggers stay quiet as well — the layer checks :disabled before it plays.Empathy
The semantics come wired from the primitive: the header renders an
h3, the trigger is a real button carrying aria-expanded and aria-controls, and the panel is a role="region" labelled by its trigger, with hidden set while closed so a screen reader never wades through collapsed answers. Keyboard follows the WAI-ARIA pattern: ArrowUp and ArrowDown walk the triggers and wrap at the ends, Home and End jump to the edges, and Radix skips disabled triggers along the way. Focus is focus-visible:ring-1 focus-visible:ring-ring, so keyboard users get a ring and mouse clicks stay clean. Under motion-reduce both halves drop together — transition-none on the caret, animate-none on the panel — and the state change lands as a snap rather than disappearing. The one honest gap is disabled styling: the primitive marks a disabled item data-disabled and keeps it out of the arrow-key path, but I haven't drawn that state, so the row doesn't look any different yet."use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { CaretDown } from "@phosphor-icons/react/dist/ssr";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {
ref?: React.Ref<React.ComponentRef<typeof AccordionPrimitive.Item>>;
}) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-border", className)}
{...props}
/>
);
AccordionItem.displayName = "AccordionItem";
// the whole row is the trigger — a disclosure you have to aim at is a
// target, not a disclosure. chevron and panel share the 200ms swift
// curve: one gesture, two readouts.
const AccordionTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
ref?: React.Ref<React.ComponentRef<typeof AccordionPrimitive.Trigger>>;
}) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between gap-4 py-4 text-left text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<CaretDown className="size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-swift motion-reduce:transition-none" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
// the keyframes ride --radix-accordion-content-height, so the curve
// animates the real measured height — no max-height fakery
const AccordionContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {
ref?: React.Ref<React.ComponentRef<typeof AccordionPrimitive.Content>>;
}) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up motion-reduce:animate-none"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
);
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };