Articulo publicado el
Aprende a construir un sistema de modales flexible, reutilizable y controlable sin depender de librerías externas.
Un modal parece un componente sencillo: una caja centrada, un fondo oscuro y un botón para cerrar. Sin embargo, cuando lo llevas a un proyecto real aparecen varios detalles importantes: cómo evitar que el fondo siga haciendo scroll, cómo cerrar con la tecla Escape, cómo soportar varios modales, cómo controlar el z-index, cómo mover el modal a un portal y cómo reutilizar la misma lógica sin copiar código en cada pantalla.
Crear un modal reutilizable con JavaScript Vanilla permite entender bien estos problemas sin depender de una librería de componentes. La idea no es reinventar todo el sistema de UI, sino construir una base sólida que puedas integrar en proyectos con HTML, Astro, Next.js, React o cualquier entorno que renderice marcado HTML.
En este post vamos a partir de una clase Modal escrita en TypeScript. Aunque puede convivir con React, la lógica principal no depende de React: trabaja directamente con el DOM, atributos data-*, clases CSS y eventos nativos.
Antes de escribir código conviene definir responsabilidades. Un modal reutilizable debería encargarse de:
data-*.Escape.Este enfoque separa la estructura HTML, los estilos CSS y la lógica de interacción. Así el modal se vuelve reutilizable en lugar de quedar acoplado a una página concreta.
El sistema espera un contenedor principal y un nodo interno de contenido. Puedes usar la clase .modal-content o el atributo [data-modal-content].
<button data-toggle="example-modal">Abrir modal</button>
<div id="example-modal" class="modal hidden">
<div class="modal-content">
<header>
<h2>Modal reutilizable</h2>
</header>
<section>
<p>Contenido del modal.</p>
</section>
<button data-toggle="example-modal">Cerrar</button>
</div>
</div>
El botón no necesita importar ninguna función. El atributo data-toggle="example-modal" conecta el trigger con el modal cuyo id es example-modal.
El siguiente código implementa la clase Modal, un registro global de instancias y varias opciones de configuración. Si lo usas en un archivo puramente Vanilla o TypeScript, puedes eliminar cualquier import de React porque la clase no lo necesita.
const MODAL_PLACEMENTS = ["top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "center", "center-start", "center-end"] as const;
const MODAL_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "full", "screen"] as const;
const MODAL_SCROLL_BEHAVIORS = ["inside", "outside"] as const;
export type ModalPlacement = (typeof MODAL_PLACEMENTS)[number];
export type ModalSize = (typeof MODAL_SIZES)[number];
export type ModalScrollBehavior = (typeof MODAL_SCROLL_BEHAVIORS)[number];
export interface ModalOptions {
placement?: ModalPlacement;
size?: ModalSize;
scrollBehavior?: ModalScrollBehavior;
staticBackdrop?: boolean;
}
export interface ModalRegistryItem {
name: string;
instance: Modal;
target: string;
backdropTarget: string;
level: number;
zIndex: number;
}
export const modalRegistry: ModalRegistryItem[] = [];
const BASE_Z_INDEX = 50;
const CLOSE_ANIMATION_DURATION = 200;
const STATIC_ANIMATION_DURATION = 80;
function includes<T extends readonly string[]>(values: T, value: unknown): value is T[number] {
return typeof value === "string" && values.includes(value);
}
function getOrCreatePortal(): HTMLElement {
let portal = document.querySelector<HTMLElement>("#modal-portal");
if (!portal) {
portal = document.createElement("div");
portal.id = "modal-portal";
document.body.appendChild(portal);
}
return portal;
}
function getModalZIndex(level: number): number {
return BASE_Z_INDEX + level * 10;
}
export class Modal {
private readonly modal: HTMLElement;
private readonly modalContent: HTMLElement;
private readonly portal: HTMLElement;
private readonly backdrop: HTMLDivElement;
private readonly options: Required<ModalOptions>;
private readonly abortController = new AbortController();
private readonly originalParent: Node | null;
private readonly originalNextSibling: ChildNode | null;
public isOpen = false;
constructor(selector: string, options: ModalOptions = {}) {
const modal = document.querySelector<HTMLElement>(selector);
if (!modal) {
throw new Error(`No se encontró el modal: ${selector}`);
}
const modalContent = modal.querySelector<HTMLElement>(".modal-content") ?? modal.querySelector<HTMLElement>("[data-modal-content]");
if (!modalContent) {
throw new Error(`No se encontró .modal-content o [data-modal-content] dentro de: ${selector}`);
}
this.modal = modal;
this.modalContent = modalContent;
this.portal = getOrCreatePortal();
this.originalParent = modal.parentNode;
this.originalNextSibling = modal.nextSibling;
this.backdrop = document.createElement("div");
this.backdrop.classList.add("modal-backdrop", "hidden");
this.backdrop.dataset.modalBackdrop = this.modal.id;
this.options = {
placement: includes(MODAL_PLACEMENTS, options.placement) ? options.placement : "center",
size: includes(MODAL_SIZES, options.size) ? options.size : "md",
scrollBehavior: includes(MODAL_SCROLL_BEHAVIORS, options.scrollBehavior) ? options.scrollBehavior : "inside",
staticBackdrop: options.staticBackdrop ?? false
};
const level = this.getOpenModalCount() + 1;
const zIndex = getModalZIndex(level);
modalRegistry.push({
name: this.modal.id,
instance: this,
target: `#${this.modal.id}`,
backdropTarget: `[data-modal-backdrop="${this.modal.id}"]`,
level,
zIndex
});
}
public init(): void {
this.modal.classList.add("hidden");
if (!this.portal.querySelector(`[data-modal-backdrop="${this.modal.id}"]`)) {
this.portal.appendChild(this.backdrop);
}
this.registerToggleTriggers();
this.registerCloseListeners();
}
public open(): void {
const level = this.getOpenModalCount() + 1;
const zIndex = getModalZIndex(level);
this.isOpen = true;
if (!this.portal.contains(this.modal)) {
this.portal.appendChild(this.modal);
}
if (!this.portal.contains(this.backdrop)) {
this.portal.appendChild(this.backdrop);
}
this.backdrop.style.setProperty("--z-index", String(zIndex));
this.modal.style.setProperty("--z-index", String(zIndex + 1));
document.body.classList.add("modal-open");
this.modal.classList.remove("hidden", "closing");
this.backdrop.classList.remove("hidden", "closing");
this.modal.classList.add("open");
this.backdrop.classList.add("open");
this.modal.dataset.placement = this.options.placement;
this.modal.dataset.size = this.options.size;
this.modal.dataset.scrollBehavior = this.options.scrollBehavior;
this.backdrop.dataset.staticBackdrop = String(this.options.staticBackdrop);
}
public close(): void {
if (!this.isOpen) return;
this.modal.classList.remove("open");
this.backdrop.classList.remove("open");
this.modal.classList.add("closing");
this.backdrop.classList.add("closing");
window.setTimeout(() => {
this.modal.classList.remove("closing");
this.backdrop.classList.remove("closing");
this.modal.classList.add("hidden");
this.backdrop.classList.add("hidden");
this.isOpen = false;
const hasOpenModals = modalRegistry.some(({ instance }) => instance.isOpen);
if (!hasOpenModals) {
document.body.classList.remove("modal-open");
}
}, CLOSE_ANIMATION_DURATION);
}
public destroy(): void {
this.abortController.abort();
this.close();
this.backdrop.remove();
if (this.originalParent && !this.originalParent.contains(this.modal)) {
this.originalParent.insertBefore(this.modal, this.originalNextSibling);
}
const index = modalRegistry.findIndex(({ instance }) => instance === this);
if (index !== -1) {
modalRegistry.splice(index, 1);
}
}
private registerToggleTriggers(): void {
const triggers = document.querySelectorAll<HTMLElement>(`[data-toggle="${this.modal.id}"]`);
triggers.forEach((trigger) => {
trigger.addEventListener(
"click",
(event) => {
event.preventDefault();
this.isOpen ? this.close() : this.open();
},
{ signal: this.abortController.signal }
);
});
}
private registerCloseListeners(): void {
this.backdrop.addEventListener("click", () => this.handleDismiss(), { signal: this.abortController.signal });
this.modal.addEventListener(
"click",
(event) => {
if (event.target === this.modal) {
this.handleDismiss();
}
},
{ signal: this.abortController.signal }
);
document.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && this.isOpen) {
this.handleDismiss();
}
},
{ signal: this.abortController.signal }
);
}
private handleDismiss(): void {
if (this.options.staticBackdrop) {
this.animateStatic();
return;
}
this.close();
}
private animateStatic(): void {
this.modalContent.classList.add("backdrop-static");
window.setTimeout(() => {
this.modalContent.classList.remove("backdrop-static");
}, STATIC_ANIMATION_DURATION);
}
private getOpenModalCount(): number {
return modalRegistry.filter(({ instance }) => instance.isOpen).length;
}
}
Una vez tengas el HTML en la página, crea la instancia y ejecuta init().
import { Modal } from "./modal";
const modal = new Modal("#example-modal", {
placement: "center",
size: "lg",
scrollBehavior: "inside",
staticBackdrop: false
});
modal.init();
A partir de este momento, cualquier elemento con data-toggle="example-modal" podrá abrir o cerrar ese modal. Esto permite que el HTML sea declarativo y que la lógica JavaScript quede centralizada.
El portal se crea con getOrCreatePortal(). Si no existe un nodo con id="modal-portal", el script lo añade al final del body.
function getOrCreatePortal(): HTMLElement {
let portal = document.querySelector<HTMLElement>("#modal-portal");
if (!portal) {
portal = document.createElement("div");
portal.id = "modal-portal";
document.body.appendChild(portal);
}
return portal;
}
Mover el modal a un portal evita muchos problemas habituales de CSS. Por ejemplo, si el modal está dentro de un contenedor con overflow: hidden, transform, position o un z-index bajo, puede quedar recortado o renderizarse por debajo de otros elementos. Al llevarlo al final del body, el modal tiene un contexto más predecible.
Cada instancia crea su propio backdrop:
this.backdrop = document.createElement("div");
this.backdrop.classList.add("modal-backdrop", "hidden");
this.backdrop.dataset.modalBackdrop = this.modal.id;
El backdrop cumple dos funciones. Visualmente oscurece la interfaz de fondo. A nivel de interacción, permite cerrar el modal cuando el usuario hace click fuera del contenido.
Cuando staticBackdrop está activo, el click exterior no cierra el modal. En su lugar se ejecuta una pequeña animación sobre .modal-content para indicar que la ventana requiere una acción explícita del usuario.
private handleDismiss(): void {
if (this.options.staticBackdrop) {
this.animateStatic();
return;
}
this.close();
}
Este comportamiento es útil en formularios críticos, confirmaciones destructivas o pasos donde cerrar accidentalmente podría provocar pérdida de datos.
Las opciones placement, size y scrollBehavior no aplican estilos directamente desde JavaScript. En su lugar, se guardan como atributos data-* en el modal.
this.modal.dataset.placement = this.options.placement;
this.modal.dataset.size = this.options.size;
this.modal.dataset.scrollBehavior = this.options.scrollBehavior;
Este patrón mantiene la responsabilidad visual en CSS. JavaScript decide el estado y CSS decide cómo se ve ese estado.
Un ejemplo básico de estilos podría ser:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* Fondo oscuro y centrado del modal */
.modal-open {
overflow: hidden;
}
.modal {
--z-index: 50; /* Updated to use CSS variable */
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: var(--z-index); /* Use CSS variable for z-index */
display: flex;
padding: 1.5rem;
}
/* Backdrop del modal */
.modal-backdrop {
--z-index: 50; /* Updated to use CSS variable */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: var(--z-index); /* Use CSS variable for z-index */
background: rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
}
.modal.hidden,
.modal-backdrop.hidden {
display: none;
}
.modal.open,
.modal-backdrop.open {
display: flex;
}
/* Contenido del modal */
.modal-content {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
position: relative;
z-index: 20;
border-radius: 0.875rem;
overflow: hidden;
width: 100%;
background-color: white;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
transform: scale(1);
opacity: 1;
transition: all 0.3s ease;
}
.modal.open .modal-content {
animation: modal-enter 0.2s ease-out forwards;
}
.modal.closing .modal-content {
animation: modal-leave 0.2s ease-out forwards;
}
.modal.open .modal-backdrop {
animation: modal-backdrop-enter 0.2s ease-out forwards;
}
.modal.closing .modal-backdrop {
animation: modal-backdrop-leave 0.2s ease-out forwards;
}
/* Animaciones */
@keyframes modal-enter {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes modal-leave {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes modal-backdrop-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-backdrop-leave {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/** Top placements **/
.modal[data-placement="top"] {
justify-content: center;
align-items: flex-start;
}
.modal[data-placement="top-start"] {
justify-content: flex-start;
align-items: flex-start;
}
.modal[data-placement="top-end"] {
justify-content: flex-end;
align-items: flex-start;
}
/** Bottom placements **/
.modal[data-placement="bottom"] {
justify-content: center;
align-items: flex-end;
}
.modal[data-placement="bottom-start"] {
justify-content: flex-start;
align-items: flex-end;
}
.modal[data-placement="bottom-end"] {
justify-content: flex-end;
align-items: flex-end;
}
/** Center placements **/
.modal[data-placement="center"] {
justify-content: center;
align-items: center;
}
.modal[data-placement="center-start"] {
justify-content: flex-start;
align-items: center;
}
.modal[data-placement="center-end"] {
justify-content: flex-end;
align-items: center;
}
/** Sizes in rem **/
.modal[data-size="xs"] .modal-content {
max-width: 15rem;
}
.modal[data-size="sm"] .modal-content {
max-width: 20rem;
}
.modal[data-size="md"] .modal-content {
max-width: 30rem;
}
.modal[data-size="lg"] .modal-content {
max-width: 50rem;
}
.modal[data-size="xl"] .modal-content {
max-width: 70rem;
}
.modal[data-size="2xl"] .modal-content {
max-width: 90rem;
}
.modal[data-size="3xl"] .modal-content {
max-width: 110rem;
}
.modal[data-size="4xl"] .modal-content {
max-width: 130rem;
}
.modal[data-size="5xl"] .modal-content {
max-width: 150rem;
}
.modal[data-size="6xl"] .modal-content {
max-width: 170rem;
}
.modal[data-size="7xl"] .modal-content {
max-width: 190rem;
}
.modal[data-size="full"] .modal-content {
max-width: 100%;
}
.modal[data-size="screen"] .modal-content {
max-width: 100vw;
max-height: 100vh;
height: 100%;
overflow-y: auto;
}
/** Modal header */
.modal-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
flex: 0 0 auto;
padding: 1.5rem;
background-color: white;
border-bottom: 1px solid #eaeaea;
}
.modal-body {
width: 100%;
padding: 1.5rem;
flex: 1 1 auto;
overflow-y: auto;
min-width: fit-content;
max-height: calc(100vh - 10rem);
}
.modal-footer {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 1.5rem;
border-top: 1px solid #eaeaea;
background-color: white;
flex: 0 0 auto;
}
/** Scroll behavior */
.modal[data-scroll-behavior="inside"] .modal-content .modal-body {
overflow-y: auto;
max-height: calc(100vh - 12rem);
}
.modal[data-scroll-behavior="outside"] {
overflow-y: auto;
}
.modal[data-scroll-behavior="outside"] .modal-content {
margin-top: auto;
margin-bottom: auto;
}
.modal[data-scroll-behavior="outside"] .modal-content .modal-body {
overflow-y: hidden;
max-height: 100%;
}
/** Static modal **/
.modal.open .modal-content.backdrop-static {
transform: scale(1.01) !important;
transition: transform 0.3s ease !important;
}
/** Botones */
.btn-primary {
background: #000;
border: 1px solid #000;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999rem;
font-size: 14px;
font-weight: medium;
height: 2.5rem;
padding: 0px 24px;
}
.btn-secondary {
background: white;
border: 1px solid #ddd;
color: black;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999rem;
font-size: 14px;
font-weight: medium;
height: 2.5rem;
padding: 0px 24px;
}
button + button {
margin-left: 1rem;
}
Puedes ampliar estos estilos con transiciones de opacidad, entrada desde arriba, blur de fondo o variantes responsive. Lo importante es que la clase Modal no queda atada a un diseño específico.
El array modalRegistry guarda las instancias creadas. Esto permite saber cuántos modales están abiertos y calcular el z-index de cada nuevo modal.
function getModalZIndex(level: number): number {
return BASE_Z_INDEX + level * 10;
}
Cuando abres un modal, se calcula su nivel según el número de modales abiertos. Después se asigna un z-index al backdrop y otro al modal.
this.backdrop.style.setProperty("--z-index", String(zIndex));
this.modal.style.setProperty("--z-index", String(zIndex + 1));
Este detalle es importante si tu proyecto permite abrir un modal desde otro modal, por ejemplo una confirmación dentro de un formulario o un selector secundario dentro de un panel principal.
Uno de los puntos más interesantes del código es el uso de AbortController para limpiar listeners.
trigger.addEventListener(
"click",
(event) => {
event.preventDefault();
this.isOpen ? this.close() : this.open();
},
{ signal: this.abortController.signal }
);
Cuando se llama a destroy(), se ejecuta:
this.abortController.abort();
Esto elimina de golpe los listeners registrados con esa señal. Es una forma limpia de evitar fugas de memoria, especialmente cuando el modal se monta y desmonta dinámicamente en aplicaciones con navegación cliente.
Este modal es una buena opción cuando necesitas control total sobre la interacción y el estilo sin introducir una dependencia pesada. Encaja bien en:
data-*.No es la mejor opción si necesitas desde el primer día un sistema completo de accesibilidad avanzada, focus trap, navegación por tabulador perfectamente controlada, roles ARIA complejos y gestión exhaustiva de lectores de pantalla. En ese caso puedes ampliar esta base o apoyarte en una librería especializada.
La implementación actual ya cubre apertura, cierre, backdrop, tamaños y múltiples instancias, pero todavía puede evolucionar. Algunas mejoras razonables serían:
role="dialog" y aria-modal="true" al abrir.aria-labelledby.Escape.modal:open y modal:close.Estas mejoras convierten el modal en una pieza más robusta para producción, sobre todo en aplicaciones con requisitos altos de accesibilidad.
Construir un modal reutilizable con JavaScript Vanilla es un ejercicio excelente para mejorar la arquitectura de componentes interactivos. La clave no está solo en abrir y cerrar una caja, sino en diseñar una API clara, separar estado y estilos, limpiar eventos correctamente y prever casos reales como múltiples instancias o backdrops estáticos.
Con esta base puedes crear modales personalizados para formularios, confirmaciones, galerías, paneles de ayuda o flujos de onboarding sin depender de una librería externa. Además, al usar atributos data-* y una clase centralizada, el código resulta fácil de inicializar, mantener y adaptar a distintos contextos del proyecto.
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.
Diseña un componente modal sólido, accesible y configurable sin depender de frameworks, usando clases utilitarias de Tailwind CSS.
Un componente accesible, reutilizable y escalable con API programatica, teclado, ARIA y registro interno