Articulo publicado el
Diseña un componente modal sólido, accesible y configurable sin depender de frameworks, usando clases utilitarias de Tailwind CSS.
Un modal parece un componente sencillo hasta que lo usas en una aplicación real. No basta con mostrar una caja encima de la página: hay que controlar el foco, bloquear el scroll del documento, cerrar con Escape, restaurar el elemento activo, evitar que varios modales se pisen entre sí y permitir variaciones de tamaño, posición y animación.
La idea de este componente es crear una clase Modal en JavaScript Vanilla que puedas reutilizar en cualquier proyecto, aunque no estés usando React, Vue o Angular. Tailwind CSS se encarga de la parte visual mediante clases utilitarias, mientras que JavaScript gestiona el comportamiento.
Este enfoque es útil cuando necesitas:
data-*.No es la mejor opción si tu aplicación ya tiene un sistema de componentes muy acoplado a un framework. En ese caso, puede ser más coherente envolver esta lógica en un hook, composable o componente nativo del framework.
El modal que vamos a construir no se limita a abrir y cerrar un bloque HTML. El objetivo es que sea una pieza sólida de interfaz, con responsabilidades claras:
.modal-content o [data-modal-content].role, aria-modal, aria-hidden y tabindex.Escape.Esta separación convierte al modal en una clase reutilizable, no en un script puntual pegado a una página concreta.
El componente espera una raíz y un contenedor interno de contenido. A partir de ahí, JavaScript se encarga de añadir clases, overlay, atributos ARIA y eventos.
<button data-open-user-modal>Abrir modal</button>
<div id="user-modal" data-size="lg" data-placement="center" data-animation="zoom">
<div class="modal-content">
<h2 class="text-xl font-semibold">Editar usuario</h2>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">Actualiza la información básica del perfil antes de guardar los cambios.</p>
<div class="mt-6 flex justify-end gap-3">
<button data-modal-close class="rounded-lg px-4 py-2 text-sm">Cancelar</button>
<button class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white">Guardar</button>
</div>
</div>
</div>
Y la inicialización puede hacerse así:
import { Modal } from "./modal.js";
const userModal = new Modal("#user-modal", {
trigger: "[data-open-user-modal]",
size: "lg",
placement: "center",
animation: "zoom"
});
La ventaja de esta API es que puedes combinar configuración por JavaScript y por atributos data-*. Para páginas pequeñas, los atributos suelen ser suficientes. Para casos más dinámicos, las opciones del constructor te dan más control.
Antes de crear la clase, definimos las variantes permitidas. Esto evita valores inesperados y hace que el componente sea más predecible.
const PLACEMENTS = new Set(["center", "center-start", "center-end", "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end"]);
const SIZES = {
xs: "max-w-xs",
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
"4xl": "max-w-4xl",
"5xl": "max-w-5xl",
"6xl": "max-w-6xl",
"7xl": "max-w-7xl",
full: "max-w-full",
screen: "max-w-screen"
};
La clave está en no mezclar reglas de negocio con clases sueltas repartidas por el código. SIZES traduce una opción semántica como lg o 4xl a una clase de Tailwind concreta. Si mañana decides cambiar lg por otra anchura, lo haces en un solo sitio.
Para las posiciones, el modal usa un contenedor fixed inset-0 con display: flex. Cada placement se convierte en una combinación de items-* y justify-*.
const PLACEMENT_CLASSES = {
center: "items-center justify-center",
"center-start": "items-center justify-start",
"center-end": "items-center justify-end",
top: "items-start justify-center",
"top-start": "items-start justify-start",
"top-end": "items-start justify-end",
bottom: "items-end justify-center",
"bottom-start": "items-end justify-start",
"bottom-end": "items-end justify-end"
};
Las animaciones siguen el mismo criterio. Cada animación define un estado inicial y un estado activo. El método open() elimina las clases iniciales y añade las activas; close() hace lo contrario.
const ANIMATIONS = {
zoom: {
enter: ["opacity-0", "scale-95"],
active: ["opacity-100", "scale-100"]
},
slideUp: {
enter: ["opacity-0", "translate-y-8"],
active: ["opacity-100", "translate-y-0"]
},
fade: {
enter: ["opacity-0"],
active: ["opacity-100"]
}
};
Este patrón funciona muy bien con Tailwind porque no necesitas generar CSS específico para cada modal. Cambias clases, no hojas de estilo.
ModalEl constructor resuelve el elemento raíz, valida que exista el contenido interno, guarda clases iniciales y prepara el estado del componente.
export class Modal {
static activeModals = [];
static baseZIndex = 50;
constructor(selectorOrElement, options = {}) {
this.root = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement;
if (!(this.root instanceof HTMLElement)) {
throw new Error("Modal: el elemento raíz no existe.");
}
this.content = this.root.querySelector(".modal-content") || this.root.querySelector("[data-modal-content]");
if (!(this.content instanceof HTMLElement)) {
throw new Error("Modal: falta .modal-content o [data-modal-content].");
}
this.rootInitialClass = this.root.className;
this.contentInitialClass = this.content.className;
this.overlay = document.createElement("div");
this.portal = null;
this.options = this.mergeOptions(options);
this.isOpen = false;
this.previousActiveElement = null;
this.triggerElements = [];
this.closeTimeout = null;
this.validateOptions();
this.portal = this.getOrCreatePortal();
this.setupDOM();
this.bindEvents();
if (this.options.defaultOpen) {
this.open({ initial: true });
}
}
}
Hay dos decisiones importantes aquí. La primera es aceptar tanto un selector como un elemento real, lo que hace que el componente sea cómodo en HTML estático y en código más dinámico. La segunda es guardar las clases iniciales de root y content, porque así puedes añadir clases personalizadas en el marcado sin perderlas durante la configuración.
data-*Un modal reutilizable no debería obligarte a configurar todo desde JavaScript. En muchos casos, el propio HTML puede declarar el tamaño, la posición o el comportamiento.
mergeOptions(options) {
const dataset = this.root.dataset;
const animationName =
options.animation ||
dataset.animation ||
"zoom";
return {
defaultOpen: options.defaultOpen ?? dataset.defaultOpen === "true",
placement: options.placement ?? dataset.placement ?? "center",
size: options.size ?? dataset.size ?? "4xl",
scrollBehavior: options.scrollBehavior ?? dataset.scrollBehavior ?? "outside",
staticBackdrop: options.staticBackdrop ?? dataset.staticBackdrop === "true",
closeOnEscape: options.closeOnEscape ?? dataset.closeOnEscape !== "false",
closeOnBackdrop: options.closeOnBackdrop ?? dataset.closeOnBackdrop !== "false",
restoreFocus: options.restoreFocus ?? true,
lockScroll: options.lockScroll ?? true,
portalId: options.portalId ?? dataset.portalId ?? "modal-portal",
trigger: options.trigger ?? dataset.trigger ?? null,
closeTrigger: options.closeTrigger ?? dataset.closeTrigger ?? "[data-modal-close]",
transitionDuration: Number(options.transitionDuration ?? dataset.transitionDuration ?? 300),
animationName,
animation: ANIMATIONS[animationName],
classNames: {
root: options.classNames?.root ?? "",
overlay: options.classNames?.overlay ?? "",
content: options.classNames?.content ?? "",
},
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
};
}
El uso de ?? es importante porque permite respetar valores booleanos explícitos como false. Por ejemplo, si pasas closeOnEscape: false, el componente no lo reemplaza por el valor del dataset ni por el valor por defecto.
El método setupDOM() aplica las clases necesarias al overlay, a la raíz y al contenido. También añade atributos ARIA para comunicar correctamente el estado del modal.
setupDOM() {
this.overlay.className = cn(
"fixed inset-0 bg-black/50 backdrop-blur-[2px]",
"opacity-0 transition-opacity duration-300 ease-out",
this.root.dataset.modalOverlayClass,
this.options.classNames.overlay,
);
this.overlay.setAttribute("aria-hidden", "true");
this.root.className = cn(
"fixed inset-0 hidden p-4 outline-none",
PLACEMENT_CLASSES[this.options.placement],
this.options.scrollBehavior === "outside" && "overflow-y-auto",
this.options.scrollBehavior === "inside" && "overflow-hidden",
this.options.classNames.root,
this.root.dataset.modalClass,
this.rootInitialClass,
);
this.root.setAttribute("role", "dialog");
this.root.setAttribute("aria-modal", "true");
this.root.setAttribute("aria-hidden", "true");
this.root.setAttribute("tabindex", "-1");
this.content.className = cn(
"relative w-full rounded-xl bg-white p-6 shadow-2xl",
"outline-none transition-all duration-300 ease-out",
"dark:bg-zinc-900 dark:text-white",
SIZES[this.options.size],
this.options.scrollBehavior === "inside" &&
"max-h-[calc(100dvh-2rem)] overflow-y-auto",
this.options.scrollBehavior === "outside" && "h-auto",
...this.options.animation.enter,
this.options.classNames.content,
this.content.dataset.modalContentClass,
this.contentInitialClass,
);
this.content.setAttribute("tabindex", "-1");
}
La función auxiliar cn() evita tener que concatenar strings manualmente y permite añadir clases condicionales de forma limpia.
const cn = (...classes) => classes.filter(Boolean).join(" ");
Con esta estructura, Tailwind no está escondido en un CSS aparte: cada decisión visual queda cerca de la lógica que la necesita. Para un componente de interfaz pequeño y reutilizable, esta cercanía suele facilitar el mantenimiento.
El método open() guarda el elemento activo actual, registra el modal en una pila global, calcula el z-index, mueve el overlay y el modal al portal, bloquea el scroll y activa la transición.
open({ initial = false } = {}) {
if (this.isOpen) return;
clearTimeout(this.closeTimeout);
this.previousActiveElement = document.activeElement;
this.isOpen = true;
Modal.activeModals.push(this);
const zIndex = Modal.baseZIndex + Modal.activeModals.length * 10;
this.root.style.zIndex = String(zIndex);
this.overlay.style.zIndex = String(zIndex - 1);
this.portal.append(this.overlay, this.root);
this.root.classList.remove("hidden");
this.root.classList.add("flex");
this.root.setAttribute("aria-hidden", "false");
this.overlay.setAttribute("aria-hidden", "false");
if (this.options.lockScroll) {
document.documentElement.classList.add("overflow-hidden");
document.body.classList.add("overflow-hidden");
}
requestAnimationFrame(() => {
this.overlay.classList.remove("opacity-0");
this.overlay.classList.add("opacity-100");
this.content.classList.remove(...this.options.animation.enter);
this.content.classList.add(...this.options.animation.active);
this.focusFirstElement();
});
if (!initial) {
this.options.onOpen(this);
}
}
requestAnimationFrame() ayuda a que el navegador pinte primero el estado inicial y después el estado activo. Sin ese pequeño salto, algunas transiciones podrían no ejecutarse porque las clases se aplicarían en el mismo ciclo de renderizado.
La pila Modal.activeModals también evita un problema habitual: cuando hay varios modales abiertos, solo el último debería responder a Escape o al trapping de foco.
Cerrar el modal no debería eliminarlo inmediatamente del DOM. Primero hay que devolver las clases al estado inicial y esperar a que termine la transición.
close() {
if (!this.isOpen) return;
this.isOpen = false;
this.root.setAttribute("aria-hidden", "true");
this.overlay.setAttribute("aria-hidden", "true");
this.overlay.classList.remove("opacity-100");
this.overlay.classList.add("opacity-0");
this.content.classList.remove(...this.options.animation.active);
this.content.classList.add(...this.options.animation.enter);
this.closeTimeout = setTimeout(() => {
this.root.classList.add("hidden");
this.root.classList.remove("flex");
this.overlay.remove();
this.root.remove();
Modal.activeModals = Modal.activeModals.filter((modal) => modal !== this);
if (Modal.activeModals.length === 0 && this.options.lockScroll) {
document.documentElement.classList.remove("overflow-hidden");
document.body.classList.remove("overflow-hidden");
}
if (
this.options.restoreFocus &&
this.previousActiveElement instanceof HTMLElement
) {
this.previousActiveElement.focus({ preventScroll: true });
}
this.options.onClose(this);
}, this.options.transitionDuration);
}
Aquí hay un detalle relevante: el scroll solo se desbloquea cuando no queda ningún modal activo. Esto evita que un modal secundario cierre y reactive el scroll mientras otro modal sigue abierto.
El modal tiene tres vías de cierre: botones internos, click en el backdrop y tecla Escape. Todas pasan por métodos controlados para respetar opciones como staticBackdrop, closeOnBackdrop y closeOnEscape.
bindEvents() {
this.handleTriggerClick = () => this.open();
this.handleBackdropClick = () => {
if (!this.options.closeOnBackdrop) return;
this.handleBackdropAction();
};
this.handleRootClick = (event) => {
const closeButton = event.target.closest(this.options.closeTrigger);
if (closeButton) {
this.close();
return;
}
if (event.target === this.root && this.options.closeOnBackdrop) {
this.handleBackdropAction();
}
};
this.handleKeyDown = (event) => {
if (!this.isOpen) return;
const topModal = Modal.activeModals.at(-1);
if (topModal !== this) return;
if (event.key === "Escape" && this.options.closeOnEscape) {
event.preventDefault();
if (this.options.staticBackdrop) {
this.shake();
} else {
this.close();
}
return;
}
if (event.key === "Tab") {
this.trapFocus(event);
}
};
document.addEventListener("keydown", this.handleKeyDown);
this.root.addEventListener("click", this.handleRootClick);
this.overlay.addEventListener("click", this.handleBackdropClick);
if (this.options.trigger) {
this.triggerElements = [...document.querySelectorAll(this.options.trigger)];
for (const trigger of this.triggerElements) {
trigger.addEventListener("click", this.handleTriggerClick);
}
}
}
El backdrop estático es un patrón útil para formularios con cambios sin guardar o acciones críticas. En vez de cerrar el modal, se ejecuta una pequeña animación de sacudida.
handleBackdropAction() {
if (this.options.staticBackdrop) {
this.shake();
return;
}
this.close();
}
Un modal no debería permitir que el usuario navegue con Tab hacia elementos que están detrás de él. Para evitarlo, se define un selector de elementos enfocables y se controla el ciclo entre el primer y el último elemento.
const FOCUSABLE_SELECTOR = ["a[href]", "button:not([disabled])", "textarea:not([disabled])", "input:not([disabled])", "select:not([disabled])", "[tabindex]:not([tabindex='-1'])"].join(",");
trapFocus(event) {
const focusableElements = [...this.content.querySelectorAll(FOCUSABLE_SELECTOR)]
.filter((element) => element.offsetParent !== null);
if (focusableElements.length === 0) {
event.preventDefault();
this.content.focus({ preventScroll: true });
return;
}
const firstElement = focusableElements.at(0);
const lastElement = focusableElements.at(-1);
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus({ preventScroll: true });
return;
}
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus({ preventScroll: true });
}
}
También conviene enfocar automáticamente el primer elemento útil al abrir el modal. Si existe un elemento con autofocus, tendrá prioridad. Si no, se busca el primer elemento enfocable. Si tampoco existe, se enfoca el propio contenedor del contenido.
focusFirstElement() {
const autofocusElement = this.content.querySelector("[autofocus]");
const firstFocusableElement = this.content.querySelector(FOCUSABLE_SELECTOR);
const target = autofocusElement || firstFocusableElement || this.content;
target.focus({ preventScroll: true });
}
Este comportamiento mejora la navegación por teclado y evita que el foco permanezca en el botón que abrió el modal mientras visualmente el usuario está interactuando con otra capa.
Mover el modal a un portal dentro de document.body evita problemas típicos con overflow, position, z-index o contenedores padre que recortan el contenido.
getOrCreatePortal() {
let portal = document.getElementById(this.options.portalId);
if (!portal) {
portal = document.createElement("div");
portal.id = this.options.portalId;
document.body.append(portal);
}
return portal;
}
Cada modal calcula su z-index a partir de la pila activa:
const zIndex = Modal.baseZIndex + Modal.activeModals.length * 10;
this.root.style.zIndex = String(zIndex);
this.overlay.style.zIndex = String(zIndex - 1);
De este modo, si abres un modal encima de otro, el nuevo queda por delante y su overlay también se coloca entre ambos contenidos.
Validar al inicio es mejor que fallar silenciosamente. Si alguien pasa placement: "middle", el modal debería avisar inmediatamente.
validateOptions() {
if (!PLACEMENTS.has(this.options.placement)) {
throw new Error(`Modal: placement inválido "${this.options.placement}".`);
}
if (!SIZES[this.options.size]) {
throw new Error(`Modal: size inválido "${this.options.size}".`);
}
if (!["inside", "outside"].includes(this.options.scrollBehavior)) {
throw new Error(`Modal: scrollBehavior debe ser "inside" o "outside".`);
}
if (!ANIMATIONS[this.options.animationName]) {
throw new Error(`Modal: animation inválida "${this.options.animationName}".`);
}
}
Esta validación convierte errores visuales difíciles de rastrear en mensajes claros durante el desarrollo.
Con el componente preparado, puedes crear diferentes modales sin duplicar lógica.
<button data-open-confirm-modal>Eliminar cuenta</button>
<div id="confirm-modal" data-size="md" data-placement="center" data-animation="slideUp" data-static-backdrop="true">
<div class="modal-content">
<h2 class="text-lg font-semibold">Confirmar eliminación</h2>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">Esta acción no se puede deshacer. Revisa la decisión antes de continuar.</p>
<div class="mt-6 flex justify-end gap-3">
<button data-modal-close class="rounded-lg px-4 py-2 text-sm">Cancelar</button>
<button class="rounded-lg bg-red-600 px-4 py-2 text-sm text-white">Eliminar</button>
</div>
</div>
</div>
const confirmModal = new Modal("#confirm-modal", {
trigger: "[data-open-confirm-modal]",
onOpen(modal) {
console.log("Modal abierto", modal);
},
onClose(modal) {
console.log("Modal cerrado", modal);
}
});
Este ejemplo usa staticBackdrop, así que el modal no se cierra al hacer click fuera ni al pulsar Escape; en su lugar, ejecuta la animación shake(). Es una buena opción para confirmaciones destructivas o procesos que no deben cerrarse accidentalmente.
destroy()Si el modal forma parte de una vista que se monta y desmonta dinámicamente, necesitas liberar eventos para evitar fugas de memoria.
destroy() {
clearTimeout(this.closeTimeout);
document.removeEventListener("keydown", this.handleKeyDown);
this.root.removeEventListener("click", this.handleRootClick);
this.overlay.removeEventListener("click", this.handleBackdropClick);
for (const trigger of this.triggerElements) {
trigger.removeEventListener("click", this.handleTriggerClick);
}
this.close();
}
En páginas HTML tradicionales quizá no llames nunca a destroy(). En interfaces que reemplazan fragmentos de DOM, rutas internas o widgets embebidos, sí es una pieza importante.
Este modal ya cubre una base robusta, pero puedes ampliarlo según el proyecto:
aria-labelledby y aria-describedby a partir de IDs declarados en el contenido.onBeforeClose.danger, form, drawer o fullscreen.placement, size y scrollBehavior.La mejora más importante sería reforzar la accesibilidad semántica enlazando el título y la descripción del modal con atributos ARIA específicos. La gestión de foco y aria-modal ya ofrecen una buena base, pero esos atributos hacen que el diálogo sea más descriptivo para tecnologías de asistencia.
Crear un modal reutilizable con JavaScript Vanilla y Tailwind CSS es una buena forma de entender todo lo que ocurre detrás de un componente aparentemente simple. La parte visual se resuelve con utilidades de Tailwind, pero la calidad real está en la lógica: foco, portal, scroll, pila de modales, cierre controlado, validación y limpieza de eventos.
El resultado es un componente portable, fácil de configurar y suficientemente flexible para distintos contextos: formularios, confirmaciones, paneles informativos, onboarding o acciones críticas. La clave está en no construir un modal para un único caso, sino una pequeña API que permita reutilizar el mismo comportamiento sin repetir código.
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.
Un componente accesible, configurable por HTML y controlable desde JavaScript moderno