Expandable card
Free · MITA card expands in place on a spring to reveal details, such as a staff profile with bookable slots or a plan's full feature list.
Installation
$ npx shadcn@latest add https://goodui.dev/r/expandable-card.jsonThe file lands in components/goodui/expandable-card.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/expandable-card works too.
Usage
import { ExpandableCard, ExpandableCardContent, ExpandableCardTrigger } from "@/components/goodui/expandable-card"
const slots = ["09:00", "10:30", "13:00", "15:30"]
export function StylistCard() {
return (
<ExpandableCard className="w-full max-w-sm">
<ExpandableCardTrigger>
<span className="block font-medium">Maya Chen</span>
<span className="block text-sm text-muted-foreground">Senior stylist · color specialist</span>
</ExpandableCardTrigger>
<ExpandableCardContent>
<p className="text-sm text-muted-foreground">Next free slots on Friday:</p>
<div className="mt-3 grid grid-cols-4 gap-2">
{slots.map((t) => (
<button key={t} type="button" className="h-9 rounded-md border text-sm hover:bg-muted">
{t}
</button>
))}
</div>
</ExpandableCardContent>
</ExpandableCard>
)
}When to use
- Staff or service cards that open to show bookable time slots, prices or a bio, without leaving the list.
- Pricing plans whose full feature list opens on demand.
- Settings sections and FAQ-style cards where each card is independent (several can be open).
- A list where only one section should be open at a time: use accordion-height.
- Details that deserve their own focus and backdrop (a full profile, a long form): use morphing-dialog or drawer-spring.
- Expanding table rows: use expandable-row-list.
| If you need | Use |
|---|---|
| Open by default | defaultOpen |
| Open state in your own state or URL | open={…} onOpenChange={…} |
| Custom indicator instead of the chevron | chevron={false} on ExpandableCardTrigger |
Examples
Controlled, one card open at a time
const [openId, setOpenId] = React.useState<string | null>(null)
{plans.map((p) => (
<ExpandableCard key={p.id} open={openId === p.id} onOpenChange={(o) => setOpenId(o ? p.id : null)}>
<ExpandableCardTrigger>{p.name} · {p.price}</ExpandableCardTrigger>
<ExpandableCardContent>
<ul className="list-disc ps-5 text-sm">{p.features.map((f) => <li key={f}>{f}</li>)}</ul>
</ExpandableCardContent>
</ExpandableCard>
))}API reference
ExpandableCard
| Prop | Type | Default |
|---|---|---|
| open Controlled open state. | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | false |
| onOpenChange Called when the trigger or Esc opens or closes the card. | (open: boolean) => void | — |
Also accepts every <div> attribute.
ExpandableCardTrigger
| Prop | Type | Default |
|---|---|---|
| chevron Show the rotating chevron on the right. | boolean | true |
Also accepts every <button> attribute.
ExpandableCardContent
Also accepts every <div> attribute.
Accessibility
| Enter / Space | On the trigger: opens or closes the card (native <button>). |
| Escape | Closes the card while focus is anywhere inside it; the event does not bubble further. |
| Tab | Moves through the trigger and then the content's controls when open. |
- ExpandableCardTrigger is a real <button> with aria-expanded and aria-controls pointing to the content.
- ExpandableCardContent has role="region" named by the trigger (aria-labelledby) and is removed from the DOM when closed, so hidden controls are never tabbable.
- When Escape closes the card from inside the content, focus returns to the trigger.
- The chevron is aria-hidden; the trigger's text is its accessible name, so keep it descriptive.
Motion
- The content grows to its natural height on a spring (stiffness 260, damping 32, mass 0.8) while fading in over 0.2 s; the inner content then rises 6px and fades in over 0.25 s after 0.05 s.
- The chevron rotates 180° on a spring (stiffness 300, damping 24). The card's shadow deepens over 300 ms when open.
- Closing runs the same animation in reverse.
- Reduced motion: content and chevron change instantly.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Always compose the three parts: ExpandableCard > ExpandableCardTrigger + ExpandableCardContent. The parts throw outside an ExpandableCard.
- Padding is built in (p-4 on the trigger, px-4 pb-4 on the content); do not add padding to the card itself.
- Trigger children go in a flexible span next to the chevron; use block spans for title and subtitle lines.
- Do not put another button inside ExpandableCardTrigger: it is already a <button>.
- For accordion behavior (one open), control open from the parent as in the example.
FAQ
Can several cards be open at once?
Yes, each card has its own state. Control open from a parent to allow only one.
Does it animate to height: auto?
Yes. The content's height animates with motion from 0 to auto, so it works with content of any size.
Related
Accordion whose panels spring open to their real height (motion height:auto) with a rotating chevron, in single or multiple mode, with arrow-key, Home and End navigation between headers.
Appointment rows that open inline into a raised card with notes and working actions, while neighbouring rows smoothly make room.
A service card expands into a booking dialog, with its image, title and price flying into place through a shared-layout spring, then folds back into the card on Esc or backdrop click.
Eight surfaces (elevated, glass, gradient border, neo, inset…) with shadow, padding, radius and accent stripes, plus hover layers (lift, 3D tilt, pointer spotlight, glow, scale) and a looping border beam.