Display

Accordion

Bloques que se abren y se cierran, solos o de uno en uno

Preview

¿Cómo anima la altura?

Con una rejilla de una sola fila que pasa de 0fr a 1fr. El navegador calcula la altura real y la transición la recorre entera, sin medir nada con JavaScript.

¿Y al cerrar?

Un details oculta su contenido en el acto. Por eso la transición incluye también content-visibility, que lo mantiene pintado hasta que la fila termina de cerrarse.

¿Qué hace exclusive?

Da el mismo name a todos los details del grupo, y el navegador deja abierto solo uno. Ni una línea de 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[]requiredLos bloques en orden, cada uno con { label, content, open? }. label es la fila que se pulsa, content lo que despliega y open lo deja abierto al cargar.
size"sm" | "md" | ResponsiveSizerequiredTamaño del texto y padding de cada fila. Fijo o responsive.
exclusiveboolean—Deja abierto un solo bloque a la vez. Sin ella, cada uno se abre por su cuenta.