A card that expands in place to reveal detail content using the grid-template-rows trick, fully interruptible mid-animation.
npx shadcn@latest add @paragon/expandable-card"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface ExpandableCardContextValue {
expanded: boolean;
toggle: () => void;
contentId: string;
triggerId: string;
isStatic: boolean;
}
const ExpandableCardContext =
React.createContext<ExpandableCardContextValue | null>(null);
function useExpandableCard(consumer: string) {
const context = React.useContext(ExpandableCardContext);
if (!context) {
throw new Error(`<${consumer}> must be used within <ExpandableCard>`);
}
return context;
}
export interface ExpandableCardProps extends React.ComponentProps<"div"> {
/** Controlled expanded state. Leave undefined for uncontrolled. */
expanded?: boolean;
/** Initial state when uncontrolled. */
defaultExpanded?: boolean;
/** Fires with the next state on every toggle. */
onExpandedChange?: (expanded: boolean) => void;
/** Disables the expand/collapse and chevron transitions. */
static?: boolean;
}
/**
* A card that expands in place to reveal detail content.
*
* Height is animated via the sanctioned `grid-template-rows: 0fr ↔ 1fr`
* trick as a CSS transition, so rapid toggles retarget from the current
* position instead of restarting. Expanding runs at `--duration-base` on
* `--ease-out`; collapsing runs quicker on `--ease-exit` — the timing on
* the destination state governs each direction.
*/
export function ExpandableCard({
expanded: controlledExpanded,
defaultExpanded = false,
onExpandedChange,
static: isStatic = false,
className,
children,
...props
}: ExpandableCardProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] =
React.useState(defaultExpanded);
const isControlled = controlledExpanded !== undefined;
const expanded = isControlled ? controlledExpanded : uncontrolledExpanded;
const id = React.useId();
const contentId = `${id}-content`;
const triggerId = `${id}-trigger`;
const toggle = React.useCallback(() => {
const next = !expanded;
if (!isControlled) setUncontrolledExpanded(next);
onExpandedChange?.(next);
}, [expanded, isControlled, onExpandedChange]);
const context = React.useMemo(
() => ({ expanded, toggle, contentId, triggerId, isStatic }),
[expanded, toggle, contentId, triggerId, isStatic],
);
return (
<ExpandableCardContext.Provider value={context}>
<div
data-slot="expandable-card"
data-expanded={expanded}
className={cn(
"rounded-xl bg-card text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover",
className,
)}
{...props}
>
{children}
</div>
</ExpandableCardContext.Provider>
);
}
export interface ExpandableCardTriggerProps
extends React.ComponentProps<"button"> {}
/**
* The card header — a real `<button>` with `aria-expanded`/`aria-controls`.
* Appends a chevron that rotates in sync with the height transition.
*/
export function ExpandableCardTrigger({
className,
children,
onClick,
...props
}: ExpandableCardTriggerProps) {
const { expanded, toggle, contentId, triggerId, isStatic } =
useExpandableCard("ExpandableCardTrigger");
return (
<button
type="button"
data-slot="expandable-card-trigger"
{...props}
id={triggerId}
aria-expanded={expanded}
aria-controls={contentId}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) toggle();
}}
className={cn(
"flex w-full items-center gap-4 rounded-[inherit] px-5 py-4 text-left",
className,
)}
>
<span className="min-w-0 flex-1">{children}</span>
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground",
expanded && "rotate-180",
!isStatic &&
"transition-[rotate] motion-reduce:transition-none",
!isStatic &&
(expanded
? "duration-(--duration-base) ease-out"
: "duration-(--duration-quick) ease-(--ease-exit)"),
)}
/>
</button>
);
}
export interface ExpandableCardContentProps
extends React.ComponentProps<"div"> {}
/**
* The collapsible region. `className` styles the padded body; structural
* wrappers (the grid and the overflow clip) are fixed. Collapsed content
* is `inert`, so nothing inside can take focus or reach assistive tech.
*/
export function ExpandableCardContent({
className,
children,
...props
}: ExpandableCardContentProps) {
const { expanded, contentId, triggerId, isStatic } =
useExpandableCard("ExpandableCardContent");
return (
<div
data-slot="expandable-card-content"
{...props}
role="region"
id={contentId}
aria-labelledby={triggerId}
data-expanded={expanded}
className={cn(
"grid",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
!isStatic &&
"transition-[grid-template-rows] motion-reduce:transition-none",
!isStatic &&
(expanded
? "duration-(--duration-base) ease-out"
: "duration-(--duration-quick) ease-(--ease-exit)"),
)}
>
<div
inert={!expanded}
className={cn(
"min-h-0 overflow-hidden",
expanded ? "opacity-100" : "opacity-0",
!isStatic &&
(expanded
? "transition-opacity duration-(--duration-base) ease-out"
: "transition-opacity duration-(--duration-quick) ease-(--ease-exit)"),
)}
>
<div className={cn("px-5 pb-5", className)}>{children}</div>
</div>
</div>
);
}