Articulo publicado el
Un componente accesible, configurable por HTML y controlable desde JavaScript moderno
Un accordion es uno de esos componentes que parecen simples hasta que empiezas a usarlo en un proyecto real. No basta con ocultar y mostrar contenido: también hay que pensar en accesibilidad, navegación con teclado, estado inicial, comportamiento configurable, integración con estilos existentes y una API que no obligue a duplicar lógica en cada página.
La idea de este artículo es construir un componente Vanilla JavaScript que puedas usar en una landing, una sección de preguntas frecuentes, una página de documentación o una interfaz de administración sin depender de React, Vue o cualquier framework de componentes.
El componente estará diseñado para funcionar con atributos HTML, pero también podrá configurarse desde JavaScript cuando necesites más control.
La guía ARIA Authoring Practices del W3C describe el accordion como un conjunto vertical de encabezados interactivos que muestran u ocultan secciones de contenido. Esa definición nos sirve como base para estructurar correctamente el HTML y los estados accesibles.
Vamos a crear un accordion con estas características:
data-*.ArrowUp, ArrowDown, Home, End y Escape.aria-expanded, aria-controls, role="region" y aria-labelledby.accordion:change para conectar el componente con otras partes de la interfaz.El HTML debe ser explícito. Cada accordion tiene un contenedor raíz con data-accordion, y cada sección interna se marca con data-accordion-item. Dentro de cada item tendremos un botón disparador y un panel de contenido.
<section id="faq-accordion" data-accordion data-accordion-multiple="false" data-accordion-open="0" class="w-full max-w-xl space-y-3">
<article data-accordion-item class="rounded-xl border border-slate-200 bg-white">
<h3>
<button type="button" data-accordion-trigger class="flex w-full items-center justify-between gap-4 px-5 py-4 text-left font-medium">
¿Qué es este accordion?
<span data-accordion-icon class="transition-transform duration-300">⌄</span>
</button>
</h3>
<div data-accordion-panel>
<div class="px-5 pb-4 text-sm text-slate-600">Es un componente reutilizable creado con JavaScript Vanilla moderno.</div>
</div>
</article>
<article data-accordion-item class="rounded-xl border border-slate-200 bg-white">
<h3>
<button type="button" data-accordion-trigger class="flex w-full items-center justify-between gap-4 px-5 py-4 text-left font-medium">
¿Puedo usar Tailwind CSS?
<span data-accordion-icon class="transition-transform duration-300">⌄</span>
</button>
</h3>
<div data-accordion-panel>
<div class="px-5 pb-4 text-sm text-slate-600">Sí. El componente no impone estilos, por lo que puedes usar Tailwind libremente.</div>
</div>
</article>
</section>
El atributo data-accordion-multiple="false" indica que solo un panel puede estar abierto a la vez. El atributo data-accordion-open="0" abre inicialmente el primer item, porque los índices empiezan en cero.
Para animar la apertura y el cierre sin calcular alturas con JavaScript, podemos usar una transición con grid-template-rows. Este patrón evita depender de valores fijos como max-height: 500px, que suelen romperse cuando el contenido cambia.
[data-accordion-panel] {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 300ms ease;
}
[data-accordion-panel] > * {
min-height: 0;
}
[data-accordion-item][data-open="true"] [data-accordion-panel] {
grid-template-rows: 1fr;
}
[data-accordion-item][data-open="true"] [data-accordion-icon] {
transform: rotate(180deg);
}
Este CSS solo se encarga del comportamiento visual mínimo. Las clases de Tailwind siguen controlando bordes, colores, espaciado, tipografía y transiciones del icono.
La clase Accordion encapsula todo el comportamiento. Recibe un selector o un elemento del DOM, lee la configuración declarativa desde dataset y permite sobrescribir opciones mediante JavaScript.
const toBoolean = (value, fallback = false) => {
if (value === null || value === undefined) return fallback;
return value === true || value === "true";
};
const toArrayIndex = (value) => {
if (!value) return [];
return String(value)
.split(",")
.map((index) => Number(index.trim()))
.filter(Number.isInteger);
};
export class Accordion {
#root;
#items;
#options;
constructor(selectorOrElement, options = {}) {
this.#root = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement;
if (!this.#root) {
throw new Error("Accordion: no se encontró el elemento raíz.");
}
this.#options = {
multiple: toBoolean(this.#root.dataset.accordionMultiple, false),
open: toArrayIndex(this.#root.dataset.accordionOpen),
closeOnEscape: toBoolean(this.#root.dataset.accordionCloseOnEscape, true),
itemSelector: "[data-accordion-item]",
triggerSelector: "[data-accordion-trigger]",
panelSelector: "[data-accordion-panel]",
...options
};
this.#items = [...this.#root.querySelectorAll(this.#options.itemSelector)];
this.#init();
}
#init() {
this.#root.setAttribute("data-accordion-ready", "true");
this.#items.forEach((item, index) => {
const trigger = item.querySelector(this.#options.triggerSelector);
const panel = item.querySelector(this.#options.panelSelector);
if (!trigger || !panel) return;
const triggerId = trigger.id || `accordion-trigger-${crypto.randomUUID()}`;
const panelId = panel.id || `accordion-panel-${crypto.randomUUID()}`;
trigger.id = triggerId;
panel.id = panelId;
trigger.setAttribute("aria-controls", panelId);
trigger.setAttribute("aria-expanded", "false");
panel.setAttribute("role", "region");
panel.setAttribute("aria-labelledby", triggerId);
item.dataset.open = "false";
trigger.addEventListener("click", () => {
this.toggle(index);
});
trigger.addEventListener("keydown", (event) => {
this.#handleKeyboard(event, index);
});
});
this.#options.open.forEach((index) => {
this.open(index, { emit: false });
});
}
#handleKeyboard(event, index) {
const triggers = this.#getTriggers();
const lastIndex = triggers.length - 1;
const actions = {
ArrowDown: () => triggers[index === lastIndex ? 0 : index + 1]?.focus(),
ArrowUp: () => triggers[index === 0 ? lastIndex : index - 1]?.focus(),
Home: () => triggers[0]?.focus(),
End: () => triggers[lastIndex]?.focus(),
Escape: () => {
if (this.#options.closeOnEscape) this.close(index);
}
};
const action = actions[event.key];
if (!action) return;
event.preventDefault();
action();
}
#getTriggers() {
return this.#items.map((item) => item.querySelector(this.#options.triggerSelector)).filter(Boolean);
}
#setState(index, isOpen, { emit = true } = {}) {
const item = this.#items[index];
if (!item) return;
const trigger = item.querySelector(this.#options.triggerSelector);
if (!trigger) return;
item.dataset.open = String(isOpen);
trigger.setAttribute("aria-expanded", String(isOpen));
if (emit) {
this.#root.dispatchEvent(
new CustomEvent("accordion:change", {
bubbles: true,
detail: {
accordion: this,
index,
item,
isOpen
}
})
);
}
}
open(index, options = {}) {
if (!this.#options.multiple) {
this.#items.forEach((_, itemIndex) => {
if (itemIndex !== index) {
this.#setState(itemIndex, false, options);
}
});
}
this.#setState(index, true, options);
}
close(index, options = {}) {
this.#setState(index, false, options);
}
toggle(index) {
const item = this.#items[index];
if (!item) return;
const isOpen = item.dataset.open === "true";
isOpen ? this.close(index) : this.open(index);
}
openAll() {
if (!this.#options.multiple) return;
this.#items.forEach((_, index) => {
this.open(index);
});
}
closeAll() {
this.#items.forEach((_, index) => {
this.close(index);
});
}
destroy() {
this.#items.forEach((item) => {
item.dataset.open = "false";
item.removeAttribute("data-open");
});
this.#root.removeAttribute("data-accordion-ready");
}
}
El componente usa campos privados como #root, #items y #options para proteger el estado interno. Esto evita que otras partes de la aplicación modifiquen accidentalmente propiedades que deberían mantenerse bajo control de la clase.
También se generan identificadores automáticos con crypto.randomUUID() cuando el botón o el panel no tienen id. Esto permite conectar aria-controls y aria-labelledby sin obligarte a escribir IDs manualmente en cada accordion.
El estado visual no vive en clases CSS generadas por JavaScript, sino en data-open="true" o data-open="false". Esta decisión hace que el componente sea fácil de inspeccionar, fácil de estilizar y compatible con cualquier sistema visual.
La accesibilidad no se añade al final: forma parte de la arquitectura del componente. Cada botón recibe aria-expanded, que indica si su panel está abierto o cerrado, y aria-controls, que apunta al panel correspondiente.
El panel recibe role="region" y aria-labelledby, conectándolo con el botón que actúa como título visible. Este patrón ayuda a que lectores de pantalla y tecnologías asistivas entiendan la relación entre el control y el contenido.
Además, la navegación con teclado permite moverse entre triggers sin depender del ratón:
ArrowDown mueve el foco al siguiente trigger.ArrowUp mueve el foco al trigger anterior.Home lleva el foco al primero.End lleva el foco al último.Escape cierra el panel actual cuando closeOnEscape está activo.Este comportamiento sigue la lógica recomendada para componentes interactivos documentada por el patrón Accordion de WAI-ARIA.
Cuando quieras que todos los accordions marcados con data-accordion se activen automáticamente, puedes recorrerlos al cargar tu módulo JavaScript.
import { Accordion } from "./Accordion.js";
document.querySelectorAll("[data-accordion]").forEach((element) => {
new Accordion(element);
});
Este enfoque es ideal para páginas estáticas, blogs, landings o sitios con Astro, Next.js, Laravel, WordPress o cualquier stack donde renderices HTML y luego añadas comportamiento progresivo.
También puedes crear una instancia concreta y pasar opciones manualmente. Esto resulta útil cuando el comportamiento depende de una configuración dinámica o de una decisión de producto.
import { Accordion } from "./Accordion.js";
const accordion = new Accordion("#faq-accordion", {
multiple: false,
open: [0]
});
En este caso, multiple: false fuerza que solo haya un item abierto a la vez. Si lo cambias a true, puedes abrir varios paneles simultáneamente y utilizar métodos como openAll().
Un componente reutilizable no debería depender de funciones globales ni acoplarse a una pantalla concreta. Por eso emitimos un evento personalizado cada vez que cambia el estado de un item.
document.addEventListener("accordion:change", (event) => {
console.log(event.detail.index, event.detail.isOpen);
});
El constructor CustomEvent permite enviar información adicional dentro de detail, y la opción bubbles: true hace que el evento pueda escucharse desde un contenedor superior o desde document. Puedes revisar el comportamiento de CustomEvent en la documentación de MDN.
Este evento puede servir para analítica, sincronizar el estado con otra parte de la interfaz, cerrar un menú lateral, actualizar una URL o guardar preferencias del usuario.
Este accordion funciona especialmente bien en interfaces donde el contenido debe ser escaneable, pero no visible todo el tiempo. Algunos escenarios habituales son:
No conviene usarlo cuando el contenido debe compararse en paralelo o cuando ocultar información puede impedir tomar una decisión. En esos casos, una tabla, una lista visible o un layout por cards puede ser más adecuado.
La base del componente ya es sólida, pero puedes extenderla según las necesidades del proyecto:
localStorage.onOpen y onClose.destroy().La ventaja de este enfoque es que cada mejora puede añadirse sin romper la API principal. El HTML sigue siendo declarativo, la clase mantiene una responsabilidad clara y la interfaz pública continúa siendo pequeña.
Crear un accordion con JavaScript Vanilla no significa renunciar a una buena arquitectura. Con una estructura HTML clara, atributos ARIA bien conectados, eventos personalizados y una API controlada, puedes construir un componente reutilizable que encaja en proyectos modernos sin depender de un framework.
Este patrón también te ayuda a pensar mejor los componentes UI: el HTML define la estructura, el CSS define la presentación, y JavaScript se encarga exclusivamente del comportamiento. Esa separación hace que el componente sea más mantenible, más portable y más fácil de adaptar a distintos proyectos.
Estos articulos pueden interesarte si te gusto este articulo.
Una colección de widgets modernos para dashboards, aplicaciones SaaS e interfaces de usuario
Diseña un componente modal sólido, accesible y configurable sin depender de frameworks, usando clases utilitarias de Tailwind CSS.
Guía completa para integrar Tailwind CSS 4 en un proyecto de Next.js utilizando la configuración oficial recomendada, optimizada para rendimiento y escalabilidad