Display

Accordion

Sections that open and close, freely or one at a time

Preview

How does the height animate?

With a one-row grid that goes from 0fr to 1fr. The browser works out the real height and the transition covers all of it, with nothing measured in JavaScript.

And when it closes?

A details element hides its content at once. That is why the transition also covers content-visibility, which keeps it painted until the row has finished closing.

What does exclusive do?

It gives every details in the group the same name, and the browser keeps only one open. Not a line of JavaScript.

size
exclusive

Code

import css from "./Accordion.module.css";
import { useId } from "react";

import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";

type Size = "sm" | "md";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface AccordionItem {
  label: string;
  content: string;
  open?: boolean;
}

interface AccordionProps {
  items: AccordionItem[];
  size: Size | ResponsiveSize;
  exclusive?: boolean;
}

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function Accordion({
  items,
  size,
  exclusive,
}: AccordionProps) {
  // Details sharing a name are one group, and the browser keeps at most one open.
  const groupName = useId();

  return (
    <div
      className={`
        ${css.accordion}
        ${getSizeClasses(size)}
      `}
    >
      {items.map((item) => (
        <details
          key={item.label}
          className={css.accordion_item}
          name={exclusive ? groupName : undefined}
          open={item.open}
        >
          <summary className={css.item_label}>
            {item.label}
            <CaretDownIcon className={css.label_caret} weight="bold" aria-hidden="true" />
          </summary>
          <p className={css.item_content}>{item.content}</p>
        </details>
      ))}
    </div>
  );
}

Props

PropTypeRequiredDescription
itemsAccordionItem[]requiredThe sections in order, each with { label, content, open? }. label is the row you press, content is what it unfolds and open leaves it open on load.
size"sm" | "md" | ResponsiveSizerequiredText size and padding of each row. Fixed or responsive.
exclusiveboolean—Keeps a single section open at a time. Without it, each one opens on its own.