Articulo publicado el
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.
La version que construimos aqui incluye:
data-*Tabstabs:changeLa 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.
El patron base es este:
data-tabsdata-tabs-triggerdata-tabs-paneldata-tabs-listLa implementacion usa una clase central Tabs que se encarga de:
data-*tabs.jsLa idea principal del archivo es encapsular toda la logica en una unica clase y mantener un registro interno para evitar instancias duplicadas.
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();
});
El HTML minimo queda limpio y declarativo:
<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>
El CSS puede ser muy simple y aun asi dejar el componente listo para usar.
.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;
}
La clase Tabs tambien se puede usar de forma directa cuando necesitas controlar el componente desde otra parte de la aplicacion.
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();
Si el componente ya fue inicializado, puedes recuperarlo sin volver a crear una instancia.
import { Tabs } from "./tabs.js";
const tabs = Tabs.getInstance("[data-tabs]");
tabs.next();
Aunque la clase tiene API programatica, el componente tambien se inicializa solo al cargar el DOM.
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.
tabs:changeCada cambio de pestaña emite un evento personalizado con informacion util para integraciones externas.
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.
El componente soporta una variante vertical sin cambiar su logica interna.
<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>
En modo manual, las flechas solo mueven el foco. Para activar la pestaña hay que pulsar Enter o Espacio.
<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>
El componente añade automaticamente la semantica necesaria para que funcione como tabs reales:
role="tablist"role="tab"role="tabpanel"aria-selectedaria-controlsaria-labelledbyaria-orientationtabindex| 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 |
| 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 |
| 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. |
Una estructura simple ya es suficiente para este componente:
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.
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.
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.
Estos articulos pueden interesarte si te gusto este articulo.
Aprende a construir un sistema de tooltips flexible, accesible y controlable sin depender de librerías externas.
Aprende a construir un sistema de modales flexible, reutilizable y controlable sin depender de librerías externas.
Guía práctica para iniciar un proyecto con Vite, configurar rutas, layouts, páginas dinámicas y una pantalla 404