• 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 Marek Piwnicki
Fotografía de Unsplash creada por Marek Piwnicki
  • HTML
  • JavaScript

Articulo publicado el 27 de junio de 2026

Por Frank Esteban Isdray Junco

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.

Por qué crear tu propio modal reutilizable

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.

Qué debe resolver un buen modal

Antes de escribir código conviene definir responsabilidades. Un modal reutilizable debería encargarse de:

  • Encontrar el elemento del modal en el DOM.
  • Moverlo a un portal global para evitar problemas de stacking context.
  • Crear y controlar su propio backdrop.
  • Abrir y cerrar con métodos públicos.
  • Escuchar triggers declarativos mediante atributos data-*.
  • Cerrar al pulsar Escape.
  • Cerrar al hacer click fuera del contenido.
  • Permitir un backdrop estático cuando no queremos cerrar al hacer click fuera.
  • Soportar tamaños, posiciones y comportamiento de scroll.
  • Limpiar listeners y nodos al destruir la instancia.

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.

Estructura HTML mínima

El sistema espera un contenedor principal y un nodo interno de contenido. Puedes usar la clase .modal-content o el atributo [data-modal-content].

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

Código del 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.

ts
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;
  }
}

Inicializar el modal

Una vez tengas el HTML en la página, crea la instancia y ejecuta init().

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

Cómo funciona el portal

El portal se crea con getOrCreatePortal(). Si no existe un nodo con id="modal-portal", el script lo añade al final del body.

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

Backdrop y cierre controlado

Cada instancia crea su propio backdrop:

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

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

Control de tamaños, posiciones y scroll

Las opciones placement, size y scrollBehavior no aplican estilos directamente desde JavaScript. En su lugar, se guardan como atributos data-* en el modal.

ts
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:

css
* {
  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.

Soporte para múltiples modales

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.

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

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

Limpieza de eventos con AbortController

Uno de los puntos más interesantes del código es el uso de AbortController para limpiar listeners.

ts
trigger.addEventListener(
  "click",
  (event) => {
    event.preventDefault();
    this.isOpen ? this.close() : this.open();
  },
  { signal: this.abortController.signal }
);

Cuando se llama a destroy(), se ejecuta:

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

Cuándo usar este patrón

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:

  • Blogs o landings con pequeños componentes interactivos.
  • Proyectos donde el diseño del modal debe ser completamente personalizado.
  • Aplicaciones que ya tienen su propio sistema de clases CSS.
  • Casos donde quieres usar HTML declarativo con data-*.
  • Prototipos que luego pueden migrarse a un componente de React, Vue o Svelte.

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.

Mejoras recomendadas

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:

  • Añadir role="dialog" y aria-modal="true" al abrir.
  • Conectar el título del modal con aria-labelledby.
  • Implementar focus trap para que el tabulador no salga del modal.
  • Devolver el foco al botón que abrió el modal al cerrarlo.
  • Cerrar solo el modal superior cuando hay varios abiertos y se pulsa Escape.
  • Emitir eventos personalizados como 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.

Demo interactiva en CodePen

Conclusión

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.

En este artículo

  1. Por qué crear tu propio modal reutilizable
  2. Qué debe resolver un buen modal
  3. Estructura HTML mínima
  4. Código del modal
  5. Inicializar el modal
  6. Cómo funciona el portal
  7. Backdrop y cierre controlado
  8. Control de tamaños, posiciones y scroll
  9. Soporte para múltiples modales
  10. Limpieza de eventos con AbortController
  11. Cuándo usar este patrón
  12. Mejoras recomendadas
  13. Demo interactiva en CodePen
  14. 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.

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 Natalia Rudomin
Imagen del articulo Tabs accesibles y escalables en JavaScript Vanilla
27 de junio de 2026
JavaScript
·Componentes
·Accesibilidad

Tabs reutilizables con JavaScript Vanilla

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