Cards

Expandable Card

A card that expands in place to reveal detail content using the grid-template-rows trick, fully interruptible mid-animation.

Install

npx shadcn@latest add @paragon/expandable-card

expandable-card.tsx

"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>
  );
}