• 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
Fotografía de Unsplash creada por Natalia Rudomin
Fotografía de Unsplash creada por Natalia Rudomin
  • JavaScript
  • Componentes
  • Accesibilidad

Articulo publicado el 27 de junio de 2026

Por Frank Esteban Isdray Junco

Tabs reutilizables con JavaScript Vanilla

Un componente accesible, reutilizable y escalable con API programatica, teclado, ARIA y registro interno

Los tabs son uno de esos componentes que parecen simples hasta que hay que llevarlos a produccion. En cuanto necesitas soporte de teclado, estados activos, orientacion vertical, API programatica y una inicializacion limpia, el componente deja de ser un bloque de HTML con botones.

Este componente de tabs esta pensado con una idea clara: que sea reutilizable, accesible y facil de integrar en proyectos reales sin depender de frameworks.

Lo que resuelve

La version que construimos aqui incluye:

  • inicializacion automatica con atributos data-*
  • API programatica mediante la clase Tabs
  • soporte completo de teclado
  • atributos ARIA correctos
  • orientacion horizontal o vertical
  • activacion automatica o manual
  • evento personalizado tabs:change
  • registro interno de instancias

La prioridad no es solo cambiar contenido visual. La prioridad es que el componente funcione bien para personas que usan teclado, lectores de pantalla y flujos de interaccion mas complejos.

Estructura del componente

El patron base es este:

  • un contenedor con data-tabs
  • una lista de triggers con data-tabs-trigger
  • un panel por cada tab con data-tabs-panel
  • opcionalmente un contenedor para la lista con data-tabs-list

La implementacion usa una clase central Tabs que se encarga de:

  1. leer opciones desde data-*
  2. preparar atributos ARIA
  3. enlazar eventos
  4. abrir la tab activa
  5. exponer metodos publicos

Archivo tabs.js

La idea principal del archivo es encapsular toda la logica en una unica clase y mantener un registro interno para evitar instancias duplicadas.

js
const orientationMap = ["horizontal", "vertical"];
const activationMap = ["auto", "manual"];

const tabsRegistry = new Map();

const defaults = {
  active: 0,
  orientation: "horizontal",
  activation: "auto",
  loop: true
};

export class Tabs {
  constructor(selector, options = {}) {
    this.root = typeof selector === "string" ? document.querySelector(selector) : selector;

    if (!this.root) {
      console.error(`No se encontro el contenedor de tabs: ${selector}`);
      return;
    }

    this.options = {
      ...defaults,
      ...this.#getDataOptions(),
      ...options
    };

    this.triggers = [...this.root.querySelectorAll("[data-tabs-trigger]")];
    this.panels = [...this.root.querySelectorAll("[data-tabs-panel]")];

    if (!this.triggers.length || !this.panels.length) {
      console.error("Faltan triggers o panels en el componente Tabs.");
      return;
    }

    this.activeIndex = this.#normalizeIndex(this.options.active);

    this.#setup();
    this.#bindEvents();
    this.open(this.activeIndex, { focus: false });

    tabsRegistry.set(this.root, this);
  }

  #getDataOptions() {
    const { tabsActive, tabsOrientation, tabsActivation, tabsLoop } = this.root.dataset;

    return {
      active: tabsActive !== undefined ? Number(tabsActive) : defaults.active,
      orientation: orientationMap.includes(tabsOrientation) ? tabsOrientation : defaults.orientation,
      activation: activationMap.includes(tabsActivation) ? tabsActivation : defaults.activation,
      loop: tabsLoop !== "false"
    };
  }

  #setup() {
    this.root.setAttribute("data-tabs-initialized", "");
    this.root.dataset.tabsOrientation = this.options.orientation;

    const tablist = this.root.querySelector("[data-tabs-list]") ?? this.root;

    tablist.setAttribute("role", "tablist");
    tablist.setAttribute("aria-orientation", this.options.orientation);

    this.triggers.forEach((trigger, index) => {
      const panel = this.panels[index];
      const triggerId = trigger.id || `tabs-trigger-${crypto.randomUUID()}`;
      const panelId = panel.id || `tabs-panel-${crypto.randomUUID()}`;

      trigger.id = triggerId;
      panel.id = panelId;

      trigger.setAttribute("type", "button");
      trigger.setAttribute("role", "tab");
      trigger.setAttribute("aria-controls", panelId);
      trigger.setAttribute("data-tabs-index", String(index));

      panel.setAttribute("role", "tabpanel");
      panel.setAttribute("aria-labelledby", triggerId);
      panel.setAttribute("data-tabs-index", String(index));
    });
  }

  #bindEvents() {
    this.triggers.forEach((trigger, index) => {
      trigger.addEventListener("click", () => {
        this.open(index);
      });

      trigger.addEventListener("keydown", (event) => {
        this.#handleKeydown(event, index);
      });
    });
  }

  #handleKeydown(event, index) {
    const keys = {
      horizontal: {
        ArrowRight: 1,
        ArrowLeft: -1
      },
      vertical: {
        ArrowDown: 1,
        ArrowUp: -1
      }
    };

    const direction = keys[this.options.orientation][event.key];

    if (direction) {
      event.preventDefault();

      const nextIndex = this.#getNextIndex(index, direction);
      const nextTrigger = this.triggers[nextIndex];

      nextTrigger.focus();

      if (this.options.activation === "auto") {
        this.open(nextIndex);
      }

      return;
    }

    if (event.key === "Home") {
      event.preventDefault();
      this.#focusOrOpen(0);
    }

    if (event.key === "End") {
      event.preventDefault();
      this.#focusOrOpen(this.triggers.length - 1);
    }

    if (this.options.activation === "manual" && (event.key === "Enter" || event.key === " ")) {
      event.preventDefault();
      this.open(index);
    }
  }

  #focusOrOpen(index) {
    this.triggers[index].focus();

    if (this.options.activation === "auto") {
      this.open(index);
    }
  }

  #getNextIndex(index, direction) {
    const nextIndex = index + direction;
    const lastIndex = this.triggers.length - 1;

    if (nextIndex < 0) {
      return this.options.loop ? lastIndex : 0;
    }

    if (nextIndex > lastIndex) {
      return this.options.loop ? 0 : lastIndex;
    }

    return nextIndex;
  }

  #normalizeIndex(index) {
    if (Number.isNaN(index)) return defaults.active;

    return Math.min(Math.max(index, 0), this.triggers.length - 1);
  }

  open(index, config = {}) {
    const nextIndex = this.#normalizeIndex(index);
    const { focus = true } = config;

    this.triggers.forEach((trigger, triggerIndex) => {
      const isActive = triggerIndex === nextIndex;
      const panel = this.panels[triggerIndex];

      trigger.setAttribute("aria-selected", String(isActive));
      trigger.setAttribute("tabindex", isActive ? "0" : "-1");
      trigger.toggleAttribute("data-active", isActive);

      panel.hidden = !isActive;
      panel.toggleAttribute("data-active", isActive);
    });

    this.activeIndex = nextIndex;

    if (focus) {
      this.triggers[nextIndex].focus();
    }

    this.root.dispatchEvent(
      new CustomEvent("tabs:change", {
        bubbles: true,
        detail: {
          index: nextIndex,
          trigger: this.triggers[nextIndex],
          panel: this.panels[nextIndex]
        }
      })
    );
  }

  next() {
    this.open(this.#getNextIndex(this.activeIndex, 1));
  }

  prev() {
    this.open(this.#getNextIndex(this.activeIndex, -1));
  }

  destroy() {
    tabsRegistry.delete(this.root);
    this.root.removeAttribute("data-tabs-initialized");
  }

  static initAll(selector = "[data-tabs]") {
    return [...document.querySelectorAll(selector)].map((element) => {
      if (tabsRegistry.has(element)) {
        return tabsRegistry.get(element);
      }

      return new Tabs(element);
    });
  }

  static getInstance(selector) {
    const element = typeof selector === "string" ? document.querySelector(selector) : selector;

    return tabsRegistry.get(element);
  }
}

document.addEventListener("DOMContentLoaded", () => {
  Tabs.initAll();
});

Ejemplo HTML basico

El HTML minimo queda limpio y declarativo:

html
<section data-tabs data-tabs-active="0" data-tabs-orientation="horizontal" data-tabs-activation="auto" data-tabs-loop="true">
  <div data-tabs-list class="tabs-list">
    <button data-tabs-trigger>HTML</button>
    <button data-tabs-trigger>CSS</button>
    <button data-tabs-trigger>JavaScript</button>
  </div>

  <div data-tabs-panel>
    <h3>HTML</h3>
    <p>Contenido de la pestaña HTML.</p>
  </div>

  <div data-tabs-panel>
    <h3>CSS</h3>
    <p>Contenido de la pestaña CSS.</p>
  </div>

  <div data-tabs-panel>
    <h3>JavaScript</h3>
    <p>Contenido de la pestaña JavaScript.</p>
  </div>
</section>

<script type="module" src="./tabs.js"></script>

CSS base

El CSS puede ser muy simple y aun asi dejar el componente listo para usar.

css
.tabs-list {
  display: flex;
  gap: 0.5rem;
  border-bottom: 1px solid #ddd;
}

[data-tabs-trigger] {
  border: 0;
  cursor: pointer;
  padding: 0.75rem 1rem;
  background: transparent;
  border-bottom: 2px solid transparent;
}

[data-tabs-trigger][data-active] {
  font-weight: 600;
  border-bottom-color: currentColor;
}

[data-tabs-panel] {
  padding-block: 1rem;
}

[data-tabs-orientation="vertical"] {
  display: grid;
  grid-template-columns: 12rem 1fr;
  gap: 1rem;
}

[data-tabs-orientation="vertical"] [data-tabs-list] {
  flex-direction: column;
  border-bottom: 0;
  border-right: 1px solid #ddd;
}

Uso programatico

La clase Tabs tambien se puede usar de forma directa cuando necesitas controlar el componente desde otra parte de la aplicacion.

js
import { Tabs } from "./tabs.js";

const tabs = new Tabs("#my-tabs", {
  active: 1,
  orientation: "horizontal",
  activation: "manual",
  loop: true
});

tabs.open(2);
tabs.next();
tabs.prev();

Obtener una instancia existente

Si el componente ya fue inicializado, puedes recuperarlo sin volver a crear una instancia.

js
import { Tabs } from "./tabs.js";

const tabs = Tabs.getInstance("[data-tabs]");

tabs.next();

Inicializacion automatica

Aunque la clase tiene API programatica, el componente tambien se inicializa solo al cargar el DOM.

js
import { Tabs } from "./tabs.js";

Tabs.initAll();
Tabs.initAll(".js-tabs");

Ese patron hace que el componente sea util tanto en paginas simples como en aplicaciones con muchos bloques repetidos.

Evento tabs:change

Cada cambio de pestaña emite un evento personalizado con informacion util para integraciones externas.

js
document.addEventListener("tabs:change", (event) => {
  const { index, trigger, panel } = event.detail;

  console.log("Indice activo:", index);
  console.log("Boton activo:", trigger);
  console.log("Panel activo:", panel);
});

Esto es especialmente util si quieres sincronizar analytics, actualizar URL, disparar logs o coordinar el componente con otro sistema.

Tabs verticales

El componente soporta una variante vertical sin cambiar su logica interna.

html
<section data-tabs data-tabs-active="0" data-tabs-orientation="vertical" data-tabs-activation="auto">
  <div data-tabs-list class="tabs-list">
    <button data-tabs-trigger>Perfil</button>
    <button data-tabs-trigger>Cuenta</button>
    <button data-tabs-trigger>Seguridad</button>
  </div>

  <div data-tabs-panel>
    <h3>Perfil</h3>
    <p>Datos personales del usuario.</p>
  </div>

  <div data-tabs-panel>
    <h3>Cuenta</h3>
    <p>Preferencias generales de la cuenta.</p>
  </div>

  <div data-tabs-panel>
    <h3>Seguridad</h3>
    <p>Opciones de contraseña y autenticacion.</p>
  </div>
</section>

Activacion manual

En modo manual, las flechas solo mueven el foco. Para activar la pestaña hay que pulsar Enter o Espacio.

html
<section data-tabs data-tabs-active="0" data-tabs-activation="manual">
  <div data-tabs-list class="tabs-list">
    <button data-tabs-trigger>Primera</button>
    <button data-tabs-trigger>Segunda</button>
    <button data-tabs-trigger>Tercera</button>
  </div>

  <div data-tabs-panel>
    <p>Contenido de la primera pestaña.</p>
  </div>

  <div data-tabs-panel>
    <p>Contenido de la segunda pestaña.</p>
  </div>

  <div data-tabs-panel>
    <p>Contenido de la tercera pestaña.</p>
  </div>
</section>

Accesibilidad incluida

El componente añade automaticamente la semantica necesaria para que funcione como tabs reales:

  • role="tablist"
  • role="tab"
  • role="tabpanel"
  • aria-selected
  • aria-controls
  • aria-labelledby
  • aria-orientation
  • gestion correcta de tabindex
  • navegacion completa con teclado

Teclas soportadas

Tecla Comportamiento
ArrowRight Siguiente tab en horizontal
ArrowLeft Tab anterior en horizontal
ArrowDown Siguiente tab en vertical
ArrowUp Tab anterior en vertical
Home Primera tab
End Ultima tab
Enter Activa la tab enfocada en modo manual
Espacio Activa la tab enfocada en modo manual

Opciones disponibles

Opcion Atributo HTML Valores Valor por defecto
active data-tabs-active Numero 0
orientation data-tabs-orientation horizontal, vertical horizontal
activation data-tabs-activation auto, manual auto
loop data-tabs-loop true, false true

Metodos publicos

Metodo Descripcion
open(index) Abre la pestaña correspondiente al indice indicado.
next() Activa la siguiente pestaña.
prev() Activa la pestaña anterior.
destroy() Elimina la instancia del registro interno.
Tabs.initAll(selector) Inicializa todas las tabs que coincidan con el selector.
Tabs.getInstance(selector) Devuelve una instancia ya inicializada.

Estructura de archivos

Una estructura simple ya es suficiente para este componente:

txt
project/
├── index.html
├── style.css
└── tabs.js

Si luego quieres convertirlo en un bloque reutilizable para mas de un proyecto, puedes moverlo a una carpeta de componentes y mantener la misma API publica.

Cierre

Este enfoque tiene una ventaja clara: el componente no queda atado a ningun framework y aun asi sigue siendo serio desde el punto de vista de accesibilidad y mantenimiento.

Cuando un componente de tabs se diseña bien desde el principio, deja de ser una pieza frágil y pasa a ser una utilidad reutilizable para landings, dashboards, paneles de configuracion y bibliotecas de UI.

En resumen: HTML declarativo, logica encapsulada, teclado, ARIA y una API pequena pero suficiente. Esa combinacion suele durar bastante mas que una implementacion improvisada.

Demo interactiva en CodePen

Conclusión

El componente de tabs reutilizable en JavaScript vanilla demuestra cómo se puede crear una solución accesible, mantenible y flexible sin depender de frameworks específicos. Al seguir buenas prácticas de accesibilidad y diseño de API, se obtiene un componente robusto que puede integrarse fácilmente en diferentes proyectos y contextos.

En este artículo

  1. Lo que resuelve
  2. Estructura del componente
  3. Archivo tabs.js
  4. Ejemplo HTML basico
  5. CSS base
  6. Uso programatico
  7. Obtener una instancia existente
  8. Inicializacion automatica
  9. Evento tabs:change
  10. Tabs verticales
  11. Activacion manual
  12. Accesibilidad incluida
  13. Teclas soportadas
  14. Opciones disponibles
  15. Metodos publicos
  16. Estructura de archivos
  17. Cierre
  18. Demo interactiva en CodePen
  19. Conclusión

Articulos relacionados

Estos articulos pueden interesarte si te gusto este articulo.

Fotografía de Unsplash creada por Carter Obasohan
Imagen del articulo Tooltip reutilizable creado con JavaScript Vanilla
27 de junio de 2026
HTML
·JavaScript

Creando un Tooltip reutilizable con JavaScript Vanilla

Aprende a construir un sistema de tooltips flexible, accesible y controlable sin depender de librerías externas.

Fotografía de Unsplash creada por Marek Piwnicki
Imagen del articulo Modal reutilizable creado con JavaScript Vanilla y TypeScript
27 de junio de 2026
HTML
·JavaScript

Creando un modal reutilizable con JavaScript Vanilla para tu proyecto

Aprende a construir un sistema de modales flexible, reutilizable y controlable sin depender de librerías externas.

Imagen de Unsplash creada por Kjell-Jostein Sivertsen
Imagen del articulo Creando una aplicación React con navegación mediante React Router
27 de junio de 2026
React
·React Router
·JavaScript

Cómo crear una app con React y React Router desde cero

Guía práctica para iniciar un proyecto con Vite, configurar rutas, layouts, páginas dinámicas y una pantalla 404