• Sobre mí
  • Proyectos
  • Contacto
  • Blog

Apasionado por el desarrollo web y el diseño de interfaces intuitivas.

Cada error es un paso. Cada línea, un aprendizaje.

📍   Salou, Tarragona, España ✉️   frankuxui.dev@gmail.com 📞   +34 641932611

Curriculum

Ver mi curriculum completo

Mapa del sitio

  • Inicio
  • Blog
  • Proyectos
  • Sobre mi
  • Mi misión
  • Sitemap

Contácto

  • Página de contácto
  • LinkedIn
  • GitHub

2026 Frankuxui - Todos los derechos reservados

  • Términos y condiciones
  • Política de privacidad
  • Política de cookies
Imagen de Unsplash creada por Carter Obasohan
Imagen de Unsplash creada por Carter Obasohan
  • JavaScript
  • Tailwind CSS
  • Diseño UI

Articulo publicado el 27 de junio de 2026

Por Frank Esteban Isdray Junco

Creando un accordion reutilizable con JavaScript Vanilla

Un componente accesible, configurable por HTML y controlable desde JavaScript moderno

Por qué crear un accordion reutilizable

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.

Qué vamos a construir

Vamos a crear un accordion con estas características:

  • Configuración mediante atributos data-*.
  • Inicialización automática desde HTML.
  • Inicialización manual desde JavaScript.
  • Soporte para un solo panel abierto o varios paneles abiertos.
  • Estado inicial configurable.
  • Navegación con teclado usando ArrowUp, ArrowDown, Home, End y Escape.
  • Atributos ARIA como aria-expanded, aria-controls, role="region" y aria-labelledby.
  • Evento personalizado accordion:change para conectar el componente con otras partes de la interfaz.
  • Compatibilidad natural con Tailwind CSS, porque el componente no impone clases visuales.

Estructura HTML del componente

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.

html
<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.

CSS mínimo necesario

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.

css
[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.

Creando la clase Accordion

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.

js
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");
  }
}

Decisiones importantes de la implementación

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.

Accesibilidad y navegación con teclado

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.

Inicialización automática por HTML

Cuando quieras que todos los accordions marcados con data-accordion se activen automáticamente, puedes recorrerlos al cargar tu módulo JavaScript.

js
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.

Inicialización manual desde JavaScript

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.

js
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().

Escuchar cambios con eventos personalizados

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.

js
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.

Casos de uso reales

Este accordion funciona especialmente bien en interfaces donde el contenido debe ser escaneable, pero no visible todo el tiempo. Algunos escenarios habituales son:

  • Preguntas frecuentes en una landing page.
  • Filtros avanzados en un ecommerce.
  • Secciones de documentación técnica.
  • Menús laterales con grupos desplegables.
  • Ajustes de usuario en un dashboard.
  • Bloques de contenido en una página de precios.

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.

Mejoras que puedes añadir después

La base del componente ya es sólida, pero puedes extenderla según las necesidades del proyecto:

  • Añadir persistencia con localStorage.
  • Permitir abrir un panel desde un parámetro de URL.
  • Añadir soporte para callbacks como onOpen y onClose.
  • Sincronizar el estado con formularios o filtros.
  • Añadir animaciones distintas usando clases de Tailwind.
  • Destruir listeners reales guardando las referencias de eventos en una versión más avanzada de 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.

Demo interactiva en CodePen

Conclusión

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.

En este artículo

  1. Por qué crear un accordion reutilizable
  2. Qué vamos a construir
  3. Estructura HTML del componente
  4. CSS mínimo necesario
  5. Creando la clase Accordion
  6. Decisiones importantes de la implementación
  7. Accesibilidad y navegación con teclado
  8. Inicialización automática por HTML
  9. Inicialización manual desde JavaScript
  10. Escuchar cambios con eventos personalizados
  11. Demo interactiva en CodePen
  12. Conclusión

Articulos relacionados

Estos articulos pueden interesarte si te gusto este articulo.

Imagen de Unsplash creada por Andrew Svk
Imagen del articulo Ejemplo de un modal reutilizable con JavaScript Vanilla y Tailwind CSS
27 de junio de 2026
Tailwind CSS
·Astro
·Diseño UI

Widgets Componentes Tailwind CSS 4

Una colección de widgets modernos para dashboards, aplicaciones SaaS e interfaces de usuario

Imagen de Unsplash creada por Kjell-Jostein Sivertsen
Imagen del articulo Ejemplo de un modal reutilizable con JavaScript Vanilla y Tailwind CSS
27 de junio de 2026
JavaScript
·Tailwind CSS
·HTML

Cómo crear un modal reutilizable con JavaScript Vanilla y Tailwind CSS

Diseña un componente modal sólido, accesible y configurable sin depender de frameworks, usando clases utilitarias de Tailwind CSS.

Fotografía de Unsplash creada por Alessio Furlan
Imagen del articulo Configurando Tailwind CSS 4 en Next.js
27 de junio de 2026
Next.js
·Tailwind CSS
·CSS

Tailwind CSS 4 en Next.js: configuración paso a paso

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