Building an Accessible, Animated Expandable Card in React

Build an accessible, animated React expandable card with TypeScript. Covers ARIA, controlled/uncontrolled state, CSS and Framer Motion, and testing.

ASOasis
7 min read
Building an Accessible, Animated Expandable Card in React

Image used for representation purposes only.

Overview

Expandable cards (also called disclosure cards) are a compact way to present summaries that reveal more detail on demand. They help reduce cognitive load, preserve vertical space, and keep layouts scannable—especially on mobile. In React, the best expandable card balances accessibility, clean state management, and smooth motion.

This guide shows how to build an accessible, animated, and easily themed ExpandableCard in React with TypeScript, then extends it with variations, testing tips, and performance guidance.

Goals and non-goals

  • Goals
    • Accessible by default: keyboard and screen reader friendly
    • Controlled and uncontrolled usage
    • Smooth animation with a CSS-only baseline (and an optional Framer Motion variant)
    • Headless-ish API surface that is easy to style
  • Non-goals
    • Full accordion system (we’ll show a quick pattern for single-open behavior)
    • Complex theming system

Quick win: the native
element

The fastest path to a disclosure is HTML’s native <details> with <summary>. It brings keyboard and screen reader support for free.

function DetailsCard() {
  return (
    <details className="card">
      <summary className="card__summary">Quarterly results</summary>
      <div className="card__content">
        <p>Revenue grew 14% YoY, driven by subscriptions.</p>
      </div>
    </details>
  );
}

Pros: minimal code, built-in semantics. Cons: limited styling for the summary marker and trickier control over animation. For customized visuals or fine-grained control, build a custom card.

The accessible, animated ExpandableCard (TypeScript)

We’ll create a self-contained component with:

  • A button that toggles the card and carries aria-expanded and aria-controls
  • Content region with role=“region” and an accessible name
  • Uncontrolled and controlled modes
  • CSS max-height transition with reduced-motion support

Component

// ExpandableCard.tsx
import React, {useCallback, useId, useLayoutEffect, useRef, useState} from 'react';

type ExpandableCardProps = {
  title: React.ReactNode;
  subtitle?: React.ReactNode;
  icon?: React.ReactNode;
  defaultExpanded?: boolean;
  expanded?: boolean; // controlled
  onExpandedChange?: (next: boolean) => void;
  disabled?: boolean;
  className?: string;
  children?: React.ReactNode;
};

export function ExpandableCard({
  title,
  subtitle,
  icon,
  defaultExpanded = false,
  expanded,
  onExpandedChange,
  disabled,
  className,
  children,
}: ExpandableCardProps) {
  const isControlled = typeof expanded === 'boolean';
  const [internalOpen, setInternalOpen] = useState(defaultExpanded);
  const isOpen = isControlled ? (expanded as boolean) : internalOpen;

  const contentRef = useRef<HTMLDivElement>(null);
  const contentId = useId();
  const headerId = useId();

  const setOpen = useCallback((next: boolean) => {
    if (disabled) return;
    if (isControlled) onExpandedChange?.(next);
    else setInternalOpen(next);
  }, [disabled, isControlled, onExpandedChange]);

  const toggle = useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);

  // Animate max-height to the element's scrollHeight
  useLayoutEffect(() => {
    const el = contentRef.current;
    if (!el) return;

    const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (prefersReduced) {
      el.style.maxHeight = isOpen ? 'none' : '0px';
      return;
    }

    if (isOpen) {
      // set to actual height for transition, then to none for natural growth after transition
      el.style.maxHeight = el.scrollHeight + 'px';
      const done = () => { el.style.maxHeight = 'none'; el.removeEventListener('transitionend', done); };
      el.addEventListener('transitionend', done);
    } else {
      // from current height to 0
      const current = el.getBoundingClientRect().height;
      el.style.maxHeight = current + 'px';
      // force reflow
      void el.offsetHeight;
      el.style.maxHeight = '0px';
    }
  }, [isOpen]);

  return (
    <section className={["card", isOpen ? 'card--open' : '', className].filter(Boolean).join(' ')} aria-labelledby={headerId}>
      <button
        id={headerId}
        type="button"
        className="card__trigger"
        aria-expanded={isOpen}
        aria-controls={contentId}
        onClick={toggle}
        disabled={disabled}
      >
        {icon && <span className="card__icon" aria-hidden>{icon}</span>}
        <span className="card__titles">
          <span className="card__title">{title}</span>
          {subtitle && <span className="card__subtitle">{subtitle}</span>}
        </span>
        <span className="card__chevron" aria-hidden />
      </button>

      <div
        id={contentId}
        role="region"
        aria-labelledby={headerId}
        ref={contentRef}
        className="card__contentWrapper"
        style={{ maxHeight: isOpen ? undefined : '0px' }}
      >
        <div className="card__content">{children}</div>
      </div>
    </section>
  );
}

Styles

/* card.css */
.card { 
  border: 1px solid hsl(240 5% 84% / 1);
  border-radius: 12px; 
  background: white; 
  box-shadow: 0 1px 2px hsl(220 3% 15% / 0.06);
}
.card__trigger { 
  display: flex; align-items: center; gap: 12px; 
  width: 100%; padding: 14px 16px; 
  background: none; border: 0; text-align: left; cursor: pointer; 
}
.card__title { font: 600 0.98rem/1.3 system-ui, sans-serif; color: hsl(222 22% 15%); }
.card__subtitle { display:block; font: 400 .85rem/1.35 system-ui, sans-serif; color: hsl(222 10% 40%); margin-top: 2px; }
.card__icon { width: 20px; height: 20px; display: inline-grid; place-items: center; color: hsl(222 12% 40%); }
.card__chevron { margin-left: auto; width: 12px; height: 12px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 200ms ease; }
.card--open .card__chevron { transform: rotate(45deg); }
.card__contentWrapper { overflow: hidden; transition: max-height 250ms ease; }
.card__content { padding: 0 16px 16px; color: hsl(222 15% 18%); }

@media (prefers-color-scheme: dark) {
  .card { background: hsl(220 10% 12%); border-color: hsl(220 8% 20%); box-shadow: 0 1px 2px hsl(220 40% 2% / 0.7); }
  .card__title { color: hsl(210 15% 92%); }
  .card__subtitle { color: hsl(210 10% 70%); }
  .card__content { color: hsl(210 12% 90%); }
}

@media (prefers-reduced-motion: reduce) {
  .card__contentWrapper, .card__chevron { transition: none; }
}

Usage

import { ExpandableCard } from './ExpandableCard';
import './card.css';

export default function ProfilePanel() {
  return (
    <ExpandableCard
      title="Usage statistics"
      subtitle="Last 30 days"
      icon={<span>📊</span>}
      defaultExpanded={false}
    >
      <ul>
        <li>Sessions: 1,248</li>
        <li>Retention: 38%</li>
        <li>Top region: North America</li>
      </ul>
    </ExpandableCard>
  );
}

Controlled vs uncontrolled

  • Uncontrolled (defaultExpanded): the component manages its own state—ideal for standalone usage.
  • Controlled (expanded/onExpandedChange): parent state is the source of truth—ideal for lists and synchronized UI.

Example: single-open accordion behavior with a list of cards.

function SingleOpenList({ items }: { items: { id: string; title: string; body: string; }[] }) {
  const [openId, setOpenId] = React.useState<string | null>(null);

  return (
    <div className="stack" role="list">
      {items.map((it) => (
        <ExpandableCard
          key={it.id}
          title={it.title}
          expanded={openId === it.id}
          onExpandedChange={(next) => setOpenId(next ? it.id : null)}
        >
          <p>{it.body}</p>
        </ExpandableCard>
      ))}
    </div>
  );
}

Accessibility checklist

  • Trigger is a native button (not a div), so keyboard and semantics are correct.
  • aria-expanded and aria-controls link the button to the region.
  • role=“region” with aria-labelledby gives the content an accessible name.
  • Respect reduced motion using prefers-reduced-motion.
  • Focus states: ensure visible focus styles in your design system.

Tip: If the card contains focusable elements, they will remain tabbable when collapsed. Because we hide using max-height and overflow, add aria-hidden to the wrapper when closed if you need stricter semantics. Alternatively, conditionally render the content only when open.

<div
  id={contentId}
  role="region"
  aria-labelledby={headerId}
  aria-hidden={!isOpen}
  hidden={!isOpen} // optional: remove from tab order/AT when closed
>
  {isOpen && <div className="card__content">{children}</div>}
</div>

Animation alternatives

If you already use Framer Motion, you can simplify height animation and orchestrate staggered content:

import { motion, AnimatePresence } from 'framer-motion';

function MotionContent({ isOpen, children, id, labelledBy }: any) {
  return (
    <AnimatePresence initial={false}>
      {isOpen && (
        <motion.div
          id={id}
          role="region"
          aria-labelledby={labelledBy}
          key="content"
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: 'auto', opacity: 1 }}
          exit={{ height: 0, opacity: 0 }}
          transition={{ duration: 0.25, ease: 'easeInOut' }}
          style={{ overflow: 'hidden' }}
        >
          <div className="card__content">{children}</div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Variations

  • Start open: defaultExpanded or expanded={true}.
  • Disabled card: pass disabled; prevent toggling and dim visuals.
  • Click-anywhere header: keep the button full-width as shown.
  • Media-rich: place a thumbnail or avatar in the icon slot.
  • Inline validation or status chips in the subtitle.

Performance tips

  • Memoize large child trees. If card content is heavy, only render when open:
    • Conditionally render children (as shown in the accessibility snippet) or
    • Use a render prop: children is a function called only when open.
  • Virtualize long lists of cards (react-window, react-virtualized) to avoid offscreen work.
  • Avoid generating new inline objects/functions in hot paths; use useCallback/useMemo where helpful.
  • Measure only when necessary. The max-height approach triggers layout reads; prefer the Framer Motion height: auto path if it’s already in your stack.

Testing the card

Use React Testing Library to assert behavior and ARIA state.

import { render, screen, fireEvent } from '@testing-library/react';
import { ExpandableCard } from './ExpandableCard';

test('toggles and updates aria-expanded', () => {
  render(<ExpandableCard title="Details">Body</ExpandableCard>);
  const btn = screen.getByRole('button', { name: /details/i });
  expect(btn).toHaveAttribute('aria-expanded', 'false');
  fireEvent.click(btn);
  expect(btn).toHaveAttribute('aria-expanded', 'true');
});

test('controlled mode calls onExpandedChange only', () => {
  const onChange = jest.fn();
  render(
    <ExpandableCard title="Controlled" expanded={false} onExpandedChange={onChange}>
      Body
    </ExpandableCard>
  );
  fireEvent.click(screen.getByRole('button', { name: /controlled/i }));
  expect(onChange).toHaveBeenCalledWith(true);
});

Common pitfalls and fixes

  • Using divs for buttons: breaks keyboard and AT support. Always use a button.
  • Animating height: auto with pure CSS: not possible; use max-height, grid tricks, or JS/Framer Motion.
  • Offscreen tabbables when collapsed: use hidden/aria-hidden or conditional rendering.
  • Forgetting reduced motion: respect user preferences.
  • Unstable layout jumps: set padding/margins on the inner content, not on the animated wrapper.

Packaging and reuse

  • Extract tokens (radii, colors, spacings) to your design system.
  • Export as a headless component that forwards className for styling libraries (Tailwind, CSS Modules, styled-components).
  • Add slots for leading/trailing actions (e.g., a kebab menu) via props or composition.

Wrap-up

An expandable card seems simple, but getting accessibility, motion, and state management right elevates the experience. Start with the accessible baseline above, then tailor motion, rendering strategy, and list behaviors to your app’s needs. With a robust foundation, you can confidently drop this component into dashboards, settings pages, onboarding flows, and beyond.

Related Posts