Acordeón
Un componente de panel de contenido plegable para mostrar y ocultar secciones de contenido relacionado, reduciendo el desorden visual mientras mantiene la información accesible.
Casos de uso
- Páginas de preguntas frecuentes con pares pregunta/respuesta
- Paneles de configuración con opciones agrupadas
- Barras laterales de navegación con secciones expandibles
- Páginas de detalles de productos con especificaciones, reseñas e información de envío
- Secciones de formularios que se expanden progresivamente
- Páginas de documentación con ejemplos de código plegables
- Paneles de filtros en interfaces de búsqueda
Implementación más simple
"use client";
import { useState } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}Un toggle mínimo usando useState. El chevron rota con una transición CSS. El contenido se renderiza condicionalmente, por lo que no afecta el DOM cuando está colapsado.
Variaciones
Apertura única (exclusiva)
"use client";
import { useState } from "react";
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionGroupProps {
items: AccordionItem[];
}
export function AccordionGroup({ items }: AccordionGroupProps) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div key={item.id}>
<button
onClick={() => setOpenId(isOpen ? null : item.id)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium text-gray-900 hover:bg-gray-50"
>
{item.title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && <div className="px-6 pb-4 text-sm text-gray-600">{item.content}</div>}
</div>
);
})}
</div>
);
}Almacena un único openId para que solo un panel esté abierto a la vez. Al hacer clic en el mismo elemento nuevamente, se cierra configurando el estado a null.
Apertura múltiple
"use client";
import { useState } from "react";
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionGroupProps {
items: AccordionItem[];
defaultOpen?: string[];
}
export function AccordionGroup({ items, defaultOpen = [] }: AccordionGroupProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
function toggle(id: string) {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
return (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{items.map((item) => {
const isOpen = openIds.has(item.id);
return (
<div key={item.id}>
<button
onClick={() => toggle(item.id)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium text-gray-900 hover:bg-gray-50"
>
{item.title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && <div className="px-6 pb-4 text-sm text-gray-600">{item.content}</div>}
</div>
);
})}
</div>
);
}Usa un Set en lugar de un ID único para que varios paneles puedan estar abiertos simultáneamente. El prop defaultOpen acepta un array de IDs para pre-expandir en mount.
Con iconos de más/menos
"use client";
import { useState } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
{open ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
)}
</span>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}Cambia entre iconos SVG de más y menos en lugar de rotar un chevron. El icono se encuentra en un fondo circular para agregar peso visual.
Contenido animado
"use client";
import { useState, useRef, useEffect } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children]);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: open ? `${height}px` : "0px" }}
>
<div ref={contentRef} className="pb-4 text-sm text-gray-600">
{children}
</div>
</div>
</div>
);
}Anima el panel abierto/cerrado transicionando maxHeight entre 0px y la scrollHeight medida. El contenido siempre está en el DOM (no se renderiza condicionalmente) para que se pueda medir la altura.
Acordeón controlado
"use client";
interface AccordionProps {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}
export function Accordion({ title, open, onToggle, children }: AccordionProps) {
return (
<div className="border-b border-gray-200">
<button
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}
// Uso:
// const [openIndex, setOpenIndex] = useState<number | null>(null);
// {items.map((item, i) => (
// <Accordion
// key={i}
// title={item.title}
// open={openIndex === i}
// onToggle={() => setOpenIndex(openIndex === i ? null : i)}
// >
// {item.content}
// </Accordion>
// ))}Una variante completamente controlada donde el padre es propietario del estado abierto/cerrado. Esto es útil cuando acciones externas (como un botón "contraer todo" o cambios de hash de URL) necesitan controlar qué paneles están abiertos.
Estilo FAQ
"use client";
import { useState } from "react";
interface FAQItem {
question: string;
answer: string;
}
interface FAQAccordionProps {
items: FAQItem[];
}
export function FAQAccordion({ items }: FAQAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="mx-auto max-w-2xl">
<dl className="divide-y divide-gray-200">
{items.map((item, index) => {
const isOpen = openIndex === index;
return (
<div key={index} className="py-4">
<dt>
<button
onClick={() => setOpenIndex(isOpen ? null : index)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between text-left text-base font-medium text-gray-900 hover:text-blue-600"
>
{item.question}
<svg
className={`ml-4 h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</dt>
{isOpen && (
<dd className="mt-3 text-sm leading-relaxed text-gray-600">
{item.answer}
</dd>
)}
</div>
);
})}
</dl>
</div>
);
}Usa elementos semánticos <dl>, <dt>, y <dd> que son apropiados para pares pregunta/respuesta. El leading-relaxed en las respuestas mejora la legibilidad para bloques de texto más largos.
Implementación compleja
"use client";
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
useId,
} from "react";
// --- Context ---
interface AccordionContextValue {
openIds: Set<string>;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext() {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error("Accordion.Item must be used inside Accordion.Root");
return ctx;
}
// --- Root ---
interface AccordionRootProps {
type?: "single" | "multiple";
defaultOpen?: string[];
children: React.ReactNode;
className?: string;
}
export function AccordionRoot({
type = "single",
defaultOpen = [],
children,
className,
}: AccordionRootProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
const toggle = useCallback(
(id: string) => {
setOpenIds((prev) => {
if (type === "single") {
return prev.has(id) ? new Set() : new Set([id]);
}
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
},
[type]
);
return (
<AccordionContext.Provider value={{ openIds, toggle }}>
<div className={`divide-y divide-gray-200 ${className ?? ""}`} role="presentation">
{children}
</div>
</AccordionContext.Provider>
);
}
// --- Item ---
interface AccordionItemProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
}
interface ItemContextValue {
value: string;
isOpen: boolean;
isDisabled: boolean;
triggerId: string;
contentId: string;
}
const ItemContext = createContext<ItemContextValue | null>(null);
function useItemContext() {
const ctx = useContext(ItemContext);
if (!ctx) throw new Error("Must be used inside Accordion.Item");
return ctx;
}
export function AccordionItem({ value, children, disabled = false }: AccordionItemProps) {
const { openIds } = useAccordionContext();
const uid = useId();
const triggerId = `accordion-trigger-${uid}`;
const contentId = `accordion-content-${uid}`;
return (
<ItemContext.Provider
value={{
value,
isOpen: openIds.has(value),
isDisabled: disabled,
triggerId,
contentId,
}}
>
<div data-state={openIds.has(value) ? "open" : "closed"}>{children}</div>
</ItemContext.Provider>
);
}
// --- Trigger ---
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
const { value, isOpen, isDisabled, triggerId, contentId } = useItemContext();
const { toggle } = useAccordionContext();
return (
<button
id={triggerId}
onClick={() => !isDisabled && toggle(value)}
aria-expanded={isOpen}
aria-controls={contentId}
aria-disabled={isDisabled}
className={`flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium transition-colors ${
isDisabled
? "cursor-not-allowed text-gray-400"
: "text-gray-900 hover:bg-gray-50 hover:text-blue-600"
}`}
>
{children}
<svg
className={`h-5 w-5 shrink-0 transition-transform duration-300 ${
isOpen ? "rotate-180" : ""
} ${isDisabled ? "text-gray-300" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// --- Content ---
export function AccordionContent({ children }: { children: React.ReactNode }) {
const { isOpen, triggerId, contentId } = useItemContext();
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children, isOpen]);
return (
<div
id={contentId}
role="region"
aria-labelledby={triggerId}
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: isOpen ? `${height}px` : "0px", opacity: isOpen ? 1 : 0 }}
>
<div ref={contentRef} className="px-6 pb-4 text-sm leading-relaxed text-gray-600">
{children}
</div>
</div>
);
}
// --- Ejemplo de uso ---
// <AccordionRoot type="single" defaultOpen={["item-1"]}>
// <AccordionItem value="item-1">
// <AccordionTrigger>¿Cuál es tu política de reembolso?</AccordionTrigger>
// <AccordionContent>Ofrecemos una garantía de devolución de dinero de 30 días.</AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-2">
// <AccordionTrigger>¿Cómo cancelo?</AccordionTrigger>
// <AccordionContent>Ve a Configuración y haz clic en Cancelar suscripción.</AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-3" disabled>
// <AccordionTrigger>Precios empresariales (próximamente)</AccordionTrigger>
// <AccordionContent>Contacta a ventas para precios empresariales.</AccordionContent>
// </AccordionItem>
// </AccordionRoot>Aspectos clave:
- Patrón de componente compuesto —
AccordionRoot,AccordionItem,AccordionTrigger, yAccordionContentse componen libremente. Cada sub-componente lee su estado del context. - Modo único vs. múltiple — el prop
typecontrola si solo un panel o muchos pueden estar abiertos. La lógica de toggle se ramifica en este valor dentro de un únicouseCallback. - Atributos ARIA —
aria-expanded,aria-controls,aria-labelledby, yrole="region"coinciden con el patrón WAI-ARIA Accordion para soporte de lector de pantalla. - useId para IDs únicos — React 19's
useIdgenera IDs estables y seguros para SSR que vinculan disparadores a sus paneles de contenido. - Altura animada —
maxHeighttransiciona de0pxa lascrollHeightmedida. Combinada conopacity, el efecto se siente suave sin jank de layout. - Elementos deshabilitados — el prop
disabledenAccordionItemdesactiva el disparador e impide el toggle, útil para secciones "próximamente" o bloqueadas.
Trampas comunes
-
La altura del contenido cambia después de mount — Si el contenido del acordeón incluye imágenes o datos asincronos, la
scrollHeightmedida puede estar mal. Re-mide la altura cuando el contenido cambia o usa unResizeObserver. -
overflow-hiddenrecortando elementos secundarios — Las sugerencias, desplegables o popovers dentro de un panel de acordeón se recortan poroverflow-hidden. Usa un portal para esos overlays anidados. -
Falta
aria-expandeden el disparador — Los lectores de pantalla dependen dearia-expandedpara anunciar si una sección está abierta o cerrada. Siempre configúralo en el elemento button. -
Usar
<div>como disparador en lugar de<button>— Un<div>cononClickno es accesible por teclado por defecto. Siempre usa un<button>para disparadores de acordeón para obtener focus incorporado y manejo de Enter/Space. -
Parpadeo de animación en la primera apertura — Si
scrollHeightse mide antes de que el contenido se renderice, la animación comienza desde0pxa0px. Mide en unuseEffectque se ejecuta después de que el contenido se monta. -
Reinicio de estado en re-render — Si el array de elementos del acordeón se recrea en cada render (literal array inline), las claves
valuedel elemento pueden no coincidir y el estado abierto se reinicia. Estabiliza el array de elementos conuseMemoo defínelo fuera del componente.