Pintar un diálogo y un aviso emergente es de las cosas que más rápido se resuelven trayendo un framework entero, y de las que peor se pagan después. Aquí verás qué hace falta de verdad para que un modal funcione bien, dónde está el trabajo real, que no es el aspecto, y cómo se resuelve todo con variables CSS y un puñado de líneas de JavaScript, sin atarte a ningún sistema de diseño. El CSS y el JavaScript son estándar y funcionan en cualquier sitio; solo la integración opcional del final va en Laravel, uno de los stacks con los que trabajamos en AndorraDev.
Un modal son tres cosas y ninguna necesita un framework
Cuando alguien necesita un diálogo y un aviso emergente, la reacción habitual es traerse una librería que además arrastra su propio sistema de diseño. Y lo que hace falta de verdad es bastante menos.
Un modal es una capa que tapa, un panel que aparece y un foco que se queda dentro. Un aviso es un elemento que entra, espera y se va. Ninguna de esas cuatro cosas exige un framework CSS: exigen unas cuantas reglas y un poco de disciplina.
El problema de resolverlo con lo que ya traes puesto es que te ata al framework. Si estás con Bootstrap, su modal viene gratis y su estética también, y sacarlo de ahí es reescribirlo. Si estás con Tailwind, cargar Bootstrap solo por el diálogo son dos sistemas de diseño conviviendo en la misma página, con sus dos resets peleándose.
El JavaScript de un modal cabe en doscientas líneas
Esto sorprende hasta que lo cuentas. El sistema completo de modales y avisos que mantenemos son 363 líneas de JavaScript, 157 para los avisos y 206 para los diálogos, sin una sola dependencia de npm. Los dos ficheros son funciones autoejecutadas, sin imports, sin build obligatorio.
Cabe porque la mayor parte del trabajo no es JavaScript. Es CSS.
Todo el estado vive en un atributo, y el CSS hace el resto
La mayoría de implementaciones caseras acaban con el JavaScript escribiendo estilos directamente, y ahí es donde se vuelven imposibles de tematizar. Hay una alternativa que cuesta lo mismo de escribir: que el JavaScript solo declare en qué estado está el diálogo y deje el aspecto entero al CSS.
modal.setAttribute('data-wm-state', state);
modal.setAttribute('aria-hidden', state === 'closed' ? 'true' : 'false');
La opacidad, la visibilidad, la transformación de entrada, el desplazamiento interno y el comportamiento en móvil los resuelven selectores del tipo [data-wm-state="open"]. Eso tiene tres consecuencias prácticas.
Puedes cambiar la animación sin tocar el JavaScript. Es una regla CSS más.
El estado es inspeccionable. Abres las herramientas del navegador y ves si el diálogo está abierto sin poner un punto de ruptura.
Y en móvil no hace falta lógica. Por debajo de 640px el panel pasa a pantalla completa con una media query, no con una comprobación de anchura en JavaScript.
El envoltorio del modal ya lleva wire:ignore.self, que es lo que impide que un redibujado de Livewire pise el atributo de estado mientras el usuario escribe en un campo. Es un detalle pequeño y es la diferencia entre un diálogo que aguanta un formulario reactivo y uno que no. Si alguna vez construyes el tuyo a mano, esa es la línea que no puedes olvidar.
El foco es la parte que casi nadie implementa
Un diálogo accesible tiene que hacer tres cosas con el teclado, y la mayoría de implementaciones caseras hacen cero.
Atrapar el tabulador dentro del panel, para que no te vayas navegando por la página de detrás. Devolver el foco al elemento que lo abrió cuando se cierra. Y respetar un autofocus explícito si lo hay, en vez de saltar siempre al primer elemento enfocable.
Las tres están resueltas: el trampa de foco intercepta Tab y Shift+Tab sobre el modal más alto de la pila, y el elemento de origen se guarda en un WeakMap para restaurarlo al cerrar. Es la clase de detalle que no se ve en una demo y se nota cuando alguien navega sin ratón.
Los mensajes se inyectan con textContent, no con innerHTML
Un aviso emergente muestra texto que muchas veces viene de fuera: el nombre de un fichero, un mensaje de error, algo que escribió un usuario. Basta una línea para decidir si eso se pinta como texto o se ejecuta como HTML:
if (title) toast.querySelector('.wt-title').textContent = title;
toast.querySelector('.wt-body').textContent = message;
Eso significa que no puedes meter negrita en un aviso, y es intencionado. A cambio, un mensaje que venga de datos del usuario no puede ejecutar nada. En un sistema donde los avisos suelen mostrar mensajes de validación construidos con lo que el usuario ha escrito, esa decisión ahorra un problema entero.
Los iconos también van dentro, como constantes: no hay dependencia de una librería de iconos ni una petición extra por mostrar un aviso.
Un solo atributo alinea toda la interfaz
La parte que más rendimiento da en el día a día es el sistema de temas en dos capas.
Hay meta-variables compartidas por toda la familia de componentes, del tipo --wire-bg, --wire-text, --wire-accent, --wire-radius, --wire-font. Y luego cada componente define las suyas derivándolas:
:root {
--wire-accent: #2563eb;
--wire-radius: 10px;
}
.wt-container {
--wt-radius: var(--wire-radius);
--wt-bg: var(--wire-bg-elevated);
}
El resultado práctico es que escribes data-wire-theme="studio" en la etiqueta <html> y los diálogos, los avisos, el banner de cookies y el widget de incidencias se alinean solos. Y para meter tu marca no hay que sobrescribir selectores peleándose con la especificidad: redefines media docena de variables.
Hay once temas incluidos, pero lo importante no es el catálogo. Es que la superficie de personalización sean variables CSS y no clases, porque eso es lo que hace que puedas cambiar el aspecto sin tocar el paquete ni bloquear la actualización.
Livewire opcional significa comprobar la clase antes de registrar nada
Declarar una dependencia como opcional en el composer.json es solo la mitad del trabajo. La otra mitad es que el código se comporte de verdad como si pudiera no estar, y ahí es donde fallan bastantes paquetes que se anuncian como agnósticos.
Livewire no está en las dependencias obligatorias, está como sugerencia. Y el proveedor de servicios comprueba que la clase exista antes de registrar el atajo:
if (! class_exists(\Livewire\Component::class)) {
return;
}
\Livewire\Component::macro('notify', function (...) { ... });
Con Livewire escribes $this->notify('Guardado', 'success') desde el componente PHP. Sin Livewire, llamas a window.notify(...) o disparas el mismo evento desde Alpine, y el paquete funciona igual. La macro no hace magia: traduce a un despacho de evento que el JavaScript escucha en window. Ese es todo el acoplamiento.
Qué hay en el mercado y en qué se diferencia
Conviene ser exacto con los compromisos de cada opción, porque todas son razonables en su contexto.
El modal de Bootstrap es sólido y está probado en millones de sitios. Si ya cargas Bootstrap entero, no tiene sentido añadir nada. El compromiso aparece cuando no lo cargas: traerlo por el diálogo son su JavaScript y su reset de estilos completos, y su aspecto es reconocible, así que personalizarlo a fondo es trabajo.
SweetAlert2 hace mucho más que un diálogo y lo hace bien. A cambio impone su propio lenguaje visual y su personalización pasa por pelearse con sus clases y sus opciones. Es la elección natural si quieres diálogos con mucha funcionalidad de serie y no te importa que se note de dónde vienen.
Toastr cubre los avisos y lleva años funcionando, pero depende de jQuery, que hoy es una dependencia que muchos proyectos ya no tienen. Notyf no depende de nada y es minúsculo, y su superficie de personalización se limita a un puñado de opciones.
La diferencia de enfoque que a nosotros nos vale es otra: los avisos y los diálogos comparten sistema de variables, así que se estilan una vez y quedan coherentes, y el mismo interruptor de tema arrastra también el banner de cookies y el widget de incidencias. Cuando el producto tiene siete plantillas distintas, eso es la diferencia entre un cambio y siete.
Cómo se ve en producción
En Crowd Legal hay 267 llamadas a los avisos repartidas en 58 ficheros y 24 diálogos en 24 vistas, todos bajo el mismo tema declarado una vez en la etiqueta <html>. En Abodara son 636 llamadas en 173 ficheros.
Y un dato que responde a la pregunta obvia: en Crowd Legal el CSS de estos componentes se importa antes de las directivas de Tailwind, en el mismo fichero. Conviven sin colisionar porque cada uno usa su prefijo de clase. Que sean independientes del framework no significa que no puedas usarlos junto a uno.
Y si no trabajas con Laravel
Los criterios se trasladan, y sirven para juzgar cualquier librería de diálogos que estés valorando:
- Que el estado viva en un atributo y el aspecto en CSS. Si la librería escribe estilos en línea desde JavaScript, personalizarla será una pelea.
- Que la personalización sea por variables, no por clases. Sobrescribir selectores ajenos rompe en cada actualización.
- Que atrape el foco y lo devuelva. Sin eso el diálogo no es accesible, por bonito que sea.
- Y que el contenido se inyecte como texto. Si tu librería de avisos acepta HTML por defecto, ya tienes un vector abierto en cuanto un mensaje incluya datos del usuario.
Dónde está el código
Los dos paquetes están en Packagist (wiretoast, wiremodal), el código en GitHub (wiretoast, wiremodal) y la documentación de los paquetes está en wiretoast y wiremodal. Hay una demo interactiva para ver los temas sin instalar nada. Son MIT.
Si estás montando una aplicación y no quieres que la interfaz te ate a un framework de estilos, esta es una de las decisiones que más barata sale tomar bien al principio. La trabajamos en diseño web y aplicaciones web, y si nos cuentas el proyecto te decimos qué encaja.