En algún lugar de producción, un modal está bloqueando actualmente un botón de pago, atrapando a un usuario que usa teclado o convirtiendo el botón Atrás en una ruleta. Y la peor parte: “funcionó en mi máquina” porque tu equipo no tenía la misma escala de fuentes, nivel de zoom, peculiaridades de iOS, enrutamiento por hash o un contexto de apilamiento extra creado por un transform que parecía inofensivo.
Los modales solo con CSS son absolutamente viables en situaciones concretas: páginas de marketing, sitios de documentación, interfaces con poca interactividad o ese caso raro de “necesito un diálogo pero legal no nos deja publicar JS en esta ruta”. Pero si los tratas como un almuerzo gratis, te cobrarán después—normalmente en deuda de accesibilidad y casos límite de navegación.
Cuándo usar modales solo con CSS (y cuándo dejar de ser valiente)
Los modales solo con CSS son mejores cuando el contenido del modal es simple, el modelo de estado es sencillo y la página no tiene un enrutador del lado cliente que te compita. Piensa en “detalles de la política de cookies”, “lightbox de imagen”, “suscripción al boletín”, “fragmento de términos”, “detalles de fila de tabla” en una página mayoritariamente estática.
No encajan bien cuando cualquiera de lo siguiente es cierto:
- Necesitas gestión real del foco (atrapar el foco, restaurarlo) y te importan los usuarios de teclado más allá de una casilla en una hoja de cumplimiento.
- Tienes modales anidados, flujos de varios pasos o múltiples superposiciones.
- Necesitas cerrar con Escape de forma fiable.
- Estás dentro de un router SPA que controla el hash, el estado del historial y la restauración del scroll.
- Necesitas prevenir interacción con el fondo de forma robusta, incluidos los lectores de pantalla.
Si estás construyendo un modal de aplicación que cambia datos del usuario, voy a ser contundente: distribuye un modal con JS y semántica de diálogo adecuada y gestión del foco. Los patrones solo con CSS que siguen son para cuando las restricciones son reales, no para cuando intentas ganar un concurso de pureza.
Una cita que funciona en operaciones y fiabilidad de UI: parafraseando una idea de John Allspaw: haz que el sistema sea fácil para hacer lo correcto y difícil para hacer lo incorrecto. Tu modal debería ser difícil de usar mal—especialmente por usuarios que no se inscribieron en tu ingenio.
Hechos y contexto: por qué existen estos patrones
- El selector
:targetviene de la era temprana del comportamiento de fragmentos de URL en CSS2: fue diseñado para navegación dentro de la página, no para máquinas de estado de UI. Lo reutilizamos porque ya estaba en todas partes. - Las superposiciones solo con CSS se popularizaron durante las oleadas “sin JS” y de “mejora progresiva” cuando el ancho de banda y los bloqueadores de scripts eran más comunes. Los patrones se quedaron.
- El elemento HTML
<dialog>es relativamente reciente en uso general y no estuvo soportado de forma consistente durante años, así que los equipos construyeron sus propios modales (en JS o CSS). backdrop-filter(fondos difuminados) llegó tarde y sigue siendo sensible al rendimiento, especialmente en GPUs móviles y en páginas complejas. No es “aderezo gratis”.- Los bugs de contextos de apilamiento explotaron cuando
position: sticky,transformyfilterse hicieron comunes. Cualquiera de estos puede crear nuevos contextos de apilamiento que invaliden tu estrategia de “solo pon z-index: 9999”. - Los fragmentos de hash afectan el historial del navegador. Cada abrir/cerrar vía
:targetpuede crear una entrada en el historial, lo que hace que el botón Atrás forme parte de tu UI te guste o no. - Los primeros scripts de lightbox inspiraron clones en CSS: el lenguaje visual (fondo atenuado + caja centrada) se volvió “estándar” antes de que las prácticas de accesibilidad se pusieran al día.
- Mobile Safari ha sido una fuente recurrente de problemas con overlays debido al redimensionamiento del viewport, comportamiento de la barra de direcciones y encadenamiento de scroll. Si tu modal “solo falla en iPhones”, no es coincidencia; es tradición.
Patrón 1: el :target modal (controlado por hash)
Cómo funciona
El selector :target aplica estilos a un elemento cuyo id coincide con el fragmento de la URL. Hacer clic en un enlace a #modal “activa” el modal cambiando el fragmento; hacer clic en un enlace a # u otro fragmento lo “desactiva”.
Suena demasiado simple porque lo es. Pero también es robusto en un aspecto importante: no depende de inputs ocultos ni asociaciones de label. Se basa en la navegación, en la que los navegadores son buenos.
Implementación mínima y funcional
cr0x@server:~$ cat modal-target.html
<!-- Trigger -->
<a href="#modal-about" class="btn">About pricing</a>
<!-- Modal container -->
<div id="modal-about" class="modal" aria-hidden="true">
<a href="#close" class="modal__backdrop" aria-label="Close"></a>
<div class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="modal-about-title">
<header class="modal__header">
<h2 id="modal-about-title">Pricing details</h2>
<a class="modal__close" href="#close" aria-label="Close dialog">×</a>
</header>
<div class="modal__body">
<p>Short copy. No form submission. No wizard.</p>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
inset: 0;
display: none;
z-index: 1000;
}
.modal:target {
display: block;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
}
.modal__dialog {
position: relative;
max-width: min(680px, calc(100vw - 2rem));
margin: 10vh auto;
background: white;
color: black;
border-radius: 12px;
padding: 1rem 1.25rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.modal__close {
float: right;
text-decoration: none;
font-size: 1.5rem;
line-height: 1;
}
</style>
Qué tiene de bueno :target
- Sin inputs ocultos. Menos trucos en el DOM.
- Estado enlazable. Puedes compartir una URL que abra el modal. A veces es una característica, a veces una demanda judicial.
- Funciona sin scripting. Obvio, pero aún útil en páginas con muchas restricciones.
Qué te va a morder
- Contaminación del historial. Cada abrir/cerrar puede crear entradas en el historial. Los usuarios presionan Atrás y el modal se reabre; presionan Atrás otra vez y la página hace scroll; ahora te odian.
- Colisiones con el router de hash. Si tu app usa enrutamiento por hash,
#modal-aboutpodría ser una ruta, no un estado de fragmento. Disfruta tu incidente. - Comportamiento de scroll hacia el target. Algunos navegadores pueden desplazarse hasta el elemento objetivo. Con
position: fixedeinset: 0normalmente no se mueve de forma visible, pero no apuestes tu tiempo de actividad a “normalmente”.
Elección de diseño: usa :target para modales de contenido en sitios multipágina. Evítalo dentro de SPAs a menos que controles el router e integres explícitamente.
Patrón 2: el modal con checkbox (:checked)
Cómo funciona
Creas una casilla oculta. Un label la activa; otro label (o el label del backdrop) la desactiva. CSS vigila input:checked y revela el modal. Es una máquina de estados disfrazada de control de formulario.
Implementación con menos trampas
cr0x@server:~$ cat modal-checkbox.html
<input id="m1" class="modal-toggle" type="checkbox" />
<label for="m1" class="btn">Open details</label>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="m1-title">
<label class="modal__backdrop" for="m1" aria-label="Close"></label>
<div class="modal__dialog">
<header class="modal__header">
<h2 id="m1-title">Details</h2>
<label class="modal__close" for="m1" aria-label="Close dialog">×</label>
</header>
<div class="modal__body">
<p>Checkbox pattern: no hash, no history entries.</p>
</div>
</div>
</div>
<style>
.modal-toggle {
position: absolute;
left: -9999px;
}
.modal {
position: fixed;
inset: 0;
display: none;
z-index: 1000;
}
.modal-toggle:checked ~ .modal {
display: block;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
cursor: pointer;
}
.modal__dialog {
position: relative;
max-width: min(680px, calc(100vw - 2rem));
margin: 10vh auto;
background: white;
border-radius: 12px;
padding: 1rem 1.25rem;
}
.modal__close {
cursor: pointer;
float: right;
font-size: 1.5rem;
line-height: 1;
}
</style>
Qué tiene de bueno
- Sin cambios de hash. El botón Atrás sigue siendo Atrás.
- Se compone con routers. Es solo estado del DOM.
- Posible tener múltiples modales si mantienes los IDs únicos y la estructura limpia.
Qué es malo (y lo es)
- Es una mentira semántica. Una casilla no es un diálogo. Los lectores de pantalla pueden anunciarlo de forma extraña; estás parcheando la semántica con ARIA.
- Restricciones en la estructura del DOM. El selector de hermanos generales (
~) significa que tu modal debe seguir a la casilla en el orden del DOM. Enhorabuena, tu arquitectura de marcado ahora depende de un truco de CSS. - El foco sigue sin gestionarse. Sin JS no puedes atrapar el foco de manera fiable ni restaurarlo al cerrar.
Opinión: si debes usar solo CSS en un entorno donde los hashes son tóxicos (enrutamiento SPA), la técnica del checkbox suele ser el mal menor. Pero documenta la restricción estructural como si fuera una API.
Estilos de backdrop que se comportan
El backdrop no es decoración; es una superficie de control
Un backdrop de modal tiene tres trabajos:
- Señalar modalidades. “Ahora estás en un diálogo”.
- Prevenir interacción. Bloquear clics a la UI subyacente.
- Ofrecer una salida. Hacer clic fuera para cerrar (cuando proceda).
En modales solo con CSS, el backdrop es tu principal límite de interacción. Si es demasiado pequeño, está mal posicionado o no intercepta realmente eventos de puntero, los usuarios harán clic a través y activarán la página detrás. Eso no es un “bug menor”. Es un plano de control roto.
Patrones de backdrop que no dejan pasar clics
- Usa un elemento posicionado de pantalla completa:
position: absolute; inset: 0;dentro de un contenedor modal fijo. - Asegura que esté debajo del diálogo pero por encima de la página: backdrop y diálogo deben estar en el mismo contexto de apilamiento; no luches con guerras globales de z-index.
- Prefiere un elemento real, no un pseudo-elemento cuando necesites que sea clicable (cerrar al hacer clic). Los pseudo-elementos pueden ser clicables, pero es mucho más fácil razonar sobre un elemento real.
Fondos difuminados: backdrop-filter es caro
Si aplicas backdrop-filter: blur(10px), le pides al navegador que muestree y difumine todo lo que hay detrás de la superposición. En una página compleja, eso puede destrozar las tasas de frames. Pruébalo en un teléfono de gama baja, no en tu portátil con 32 núcleos.
cr0x@server:~$ cat backdrop.css
.modal__backdrop {
background: rgba(0,0,0,0.45);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
Usa el desenfoque con moderación. Si el rendimiento es una preocupación, un simple backdrop semitransparente es la respuesta aburrida y correcta. Lo aburrido está bien. Lo aburrido se lanza.
Patrones de cierre: hazlo obvio, hazlo seguro
El cierre debe ser redundante
Los usuarios deberían poder cerrar un modal mediante:
- Un botón visible de cierre en la esquina superior derecha (o superior izquierda en algunos contextos RTL).
- Hacer clic en el backdrop (para diálogos no destructivos y no críticos).
- Navegación por teclado hasta un control de cierre (tab hasta él, activarlo).
En patrones solo con CSS, no obtienes Escape-para-cerrar de forma fiable. No finjas que sí. Si el modal es lo bastante crítico como para que los usuarios instintivamente presionen Escape, es lo bastante crítico como para justificar JS.
Notas de diseño del botón de cierre
- El área de impacto importa. Hazla al menos de 40×40 píxeles CSS. “×” a 12px es un desafío, no un control.
- Usa una etiqueta explícita.
aria-label="Close dialog"no es opcional. - Colócalo temprano en el DOM dentro del diálogo para que los usuarios de teclado lo alcancen rápido.
Broma #1: Lo único más persistente que un modal que no cierra es la persona que insiste en que “es un ajuste rápido de CSS”.
Cerrar con backdrop: cuándo desactivarlo
Hacer clic fuera para cerrar es agradable para lightboxes y paneles informativos. Es arriesgado para formularios, confirmaciones o cualquier cosa donde un clic accidental pueda perder datos del usuario. Para esos casos, deja el backdrop inerte (sin cerrar al clic) y haz el cierre explícito.
En el mundo solo-CSS, desactivar el cierre por backdrop suele significar: el backdrop es un <div> en lugar de un enlace/label; bloquea clics pero no cambia el estado.
Chequeo de realidad de accesibilidad (sin excusas)
No puedes implementar completamente un modal accesible solo con CSS
Seamos directos. Un modal real necesita:
- Que el foco se mueva al diálogo al abrir.
- Que el foco quede atrapado dentro del diálogo mientras esté abierto.
- Que el foco se restaure al desencadenante al cerrar.
- Que los lectores de pantalla no puedan navegar el contenido subyacente.
- Que Escape cierre el diálogo (comportamiento esperado).
CSS por sí solo no puede gestionar transiciones de estado de foco. Puedes aproximarlo con autofocus en contextos limitados, pero no puedes atrapar foco de forma fiable sin JS. Así que: si tu modal es un elemento central de la UI, deja de intentar hacerlo solo con CSS.
Qué puedes hacer todavía (y deberías)
- Usa estructura semántica:
role="dialog",aria-modal="true"yaria-labelledby. - Oculta el modal cuando esté cerrado:
display: nonelo elimina del árbol de accesibilidad. Bien. - Asegura que los controles de cierre sean activables por teclado: enlaces (
<a>) o botones (<button>). Los labels pueden activarse pero son menos obvios para tecnología asistiva. - No uses
aria-hidden="true"como un toggle en patrones solo CSS. No puedes voltearlo sin JS; dejarlo tirado es peor que omitirlo.
Mejor alternativa si puedes usar JS mínimo: <dialog>
Si se permite JS aunque sea poco, usa <dialog> y llama a showModal(). Te da un backdrop real y mejor semántica. Seguirás necesitando políticas de foco y cierre, pero empiezas desde un primitivo del navegador, no desde un truco.
Z-index y contextos de apilamiento: el asesino silencioso del modal
Por qué “z-index: 9999” no funciona
El z-index solo compara elementos dentro del mismo contexto de apilamiento. Y los contextos de apilamiento los crean cosas que añades por razones no relacionadas:
transform(inclusotranslateZ(0))filter,backdrop-filteropacity < 1position+z-indexen ciertas combinacionesisolation: isolatecontain: painty colegas
Así que tu modal puede tener z-index 1000000 y aun así renderizar debajo de un header sticky que vive en un contexto de apilamiento diferente con un z-index local menor. Aquí es donde la gente empieza a añadir valores de z-index al azar hasta que el CSS parece una BIOS overclockeada.
Cómo evitar la pelea
- Monta el modal cerca del final de
<body>. Mantenlo fuera del anidamiento de componentes que aplica transforms. - Mantén el contenedor del modal como la raíz de apilamiento:
position: fixedmás unz-indexclaro. - Evita poner
transformenbodyo en grandes wrappers de layout si también dependes de overlays fijos. Ese es un clásico pie en falso.
Bloqueo de scroll y trampas del viewport móvil
El bloqueo de scroll solo con CSS es parcial en el mejor de los casos
En un modal con JS normalmente pondrías body { overflow: hidden; } mientras esté abierto. Los patrones solo con CSS no pueden alternar eso globalmente sin selectores avanzados y estructura cuidadosa.
A veces puedes usar :has() (donde esté soportado) para bloquear el scroll cuando un modal está targeted/checked. Pero depender de :has() para comportamiento central todavía tiene riesgo de compatibilidad en algunos entornos empresariales.
cr0x@server:~$ cat scroll-lock.css
/* Only if you can rely on :has() support */
html:has(.modal:target),
html:has(.modal-toggle:checked) {
overflow: hidden;
}
Sin :has(), el compromiso práctico es:
- Haz el contenedor modal
position: fixed; inset: 0; - Haz que el cuerpo del diálogo sea desplazable con
max-heightyoverflow: auto - Acepta que la página de fondo puede seguir desplazándose en algunas circunstancias (especialmente el efecto de rebote de iOS)
Prevenir encadenamiento de scroll y brillo por overscroll
Cuando el cuerpo del diálogo alcanza los límites de scroll, los navegadores pueden “encadenar” el scroll a la página detrás. Puedes reducir esto con:
cr0x@server:~$ cat overscroll.css
.modal__dialog {
max-height: 80vh;
overflow: auto;
overscroll-behavior: contain;
}
No lo solucionará todo en cada navegador móvil, pero reduce la peor sensación de “desplazar la página detrás del modal”.
Guía de diagnóstico rápido
Estás de guardia por un incidente de UI. Un modal está atascado abierto, no se abre o bloquea clics. Aquí está lo que revisas primero, segundo, tercero—porque vagar por DevTools no es una estrategia.
1) Primera comprobación: ¿se está activando realmente el modal?
- Para
:target: ¿coincide el fragmento de URL con eliddel modal? Si no, el selector nunca se aplicará. - Para checkbox: ¿está el checkbox
checkeden el DOM? Si no, el CSS no puede revelar nada.
2) Segunda comprobación: ¿está visible pero detrás de algo?
- Inspecciona el
z-indexcalculado del contenedor modal y si está en el contexto de apilamiento esperado. - Busca transforms/filters en padres que creen contextos de apilamiento.
3) Tercera comprobación: ¿está visible pero no interactivo?
- Verifica si el backdrop está interceptando clics o dejándolos pasar (pointer-events, tamaño, posicionamiento).
- Comprueba si el diálogo está fuera de pantalla por márgenes o cambios de viewport (barra de direcciones móvil, zoom).
4) Cuarta comprobación: comportamiento de navegación e historial
- Si los usuarios reportan “Atrás reabre el modal”, estás tratando con entradas de historial de
:target. - Si la ruta SPA cambia inesperadamente al abrir, el hash lo está gestionando el router.
Errores comunes: síntomas → causa raíz → solución
Los clics atraviesan el backdrop y activan botones detrás
Síntomas: el usuario hace clic fuera del diálogo; los elementos subyacentes se activan. A veces el modal también se cierra, a veces no.
Causa raíz: el backdrop no cubre todo el viewport (falta inset), o el backdrop tiene pointer-events: none, o está detrás del contenido debido a un contexto de apilamiento.
Solución: asegúrate de que el backdrop esté posicionado y dimensionado correctamente, y asegúrate de que el contenedor del modal cree un contexto de apilamiento predecible.
El modal se abre pero queda parcialmente fuera de pantalla en móvil
Síntomas: la parte superior del diálogo está oculta bajo la barra de direcciones; el botón de cerrar es inalcanzable; el contenido se desplaza de forma extraña.
Causa raíz: margin: 10vh auto junto con unidades de viewport dinámicas que se comportan distinto; también común cuando la fuente se aumenta.
Solución: usa max-height y desplazamiento interno; considera margin: 2rem auto y centrar con align-items usando flex.
El modal se niega a aparecer en producción pero funciona localmente
Síntomas: el enlace “Abrir” cambia la URL o la casilla se altera, pero no aparece nada.
Causa raíz: el empaquetado/minificación de CSS cambió la especificidad/orden del selector; una regla posterior anula display: block; o el contenedor modal falta por diferencias en plantillas.
Solución: verifica estilos calculados en la build de producción; reduce la dependencia de juegos de especificidad; coloca estilos de modal cerca del scope del componente con selectores explícitos.
El comportamiento del botón Atrás se siente roto
Síntomas: Atrás cierra el modal, luego Atrás lo reabre; o Atrás salta a posiciones de scroll extrañas.
Causa raíz: :target añade entradas al historial y activa la semántica de desplazamiento hacia fragmentos.
Solución: usa el patrón del checkbox o gestiona el historial con JS; si debes usar :target, acepta que Atrás forma parte de la UX y diseña en consecuencia.
El header sticky se superpone al modal
Síntomas: el header es visible por encima del modal/backdrop; los usuarios aún pueden hacer clic en elementos del header.
Causa raíz: el header está en un contexto de apilamiento superior; el modal está atrapado en uno inferior por un ancestro transformado.
Solución: mueve el modal al final del body; elimina transforms de los ancestros; crea explícitamente un contexto de apilamiento en la raíz del modal.
Los usuarios de teclado se pierden o quedan atrapados
Síntomas: al tabular se llega a la página detrás del modal; el foco empieza detrás del modal; al cerrar el foco queda al inicio de la página.
Causa raíz: falta de gestión del foco (limitación de solo CSS).
Solución: si la accesibilidad importa (y sí importa), usa JS o <dialog>. Si te obligan a usar solo CSS, mantén el contenido mínimo y asegura que el control de cierre esté temprano y visible.
Tareas prácticas con comandos, salidas y decisiones
Estos son el tipo de chequeos “haz esto ahora” que ejecuto cuando un modal solo-CSS se porta mal entre entornos. Son intencionalmente concretos. Cada tarea incluye un comando, salida de ejemplo, qué significa y la decisión que tomas.
Tarea 1: Confirmar que :target realmente coincide
cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlparse
u="https://example.test/page.html#modal-about"
p=urlparse(u)
print("fragment:", p.fragment)
PY
fragment: modal-about
Qué significa: el navegador hará target en id="modal-about".
Decisión: si el fragmento no coincide exactamente con el id del modal (sensible a mayúsculas), deja de depurar CSS y arregla el marcado/enlaces.
Tarea 2: Verificar que el id del modal existe en el HTML compilado
cr0x@server:~$ grep -R --line-number 'id="modal-about"' dist/
dist/page.html:214:<div id="modal-about" class="modal">
Qué significa: el elemento existe en la salida de producción.
Decisión: si grep no encuentra nada, tu pipeline de build lo eliminó o renombró (templates, bloques CMS, partials). Arregla la build, no el CSS.
Tarea 3: Comprobar que el CSS del modal sobrevivió a minificación/empaquetado
cr0x@server:~$ grep -R --line-number '\.modal:target' dist/assets/
dist/assets/app.min.css:1:.modal:target{display:block}
Qué significa: el selector crítico existe.
Decisión: si falta, tu tree-shaker de CSS lo purgó (común con setups utility-first). Añade reglas a la lista blanca o reestructura el CSS.
Tarea 4: Detectar si el tooling purga selectores “sin usar”
cr0x@server:~$ rg "content:" tailwind.config.js
18: content: ["./src/**/*.html","./src/**/*.js"],
Qué significa: el purge solo escanea esos archivos.
Decisión: si el HTML del modal se inyecta desde un CMS o markdown no cubierto, purge puede eliminar estilos del modal. Añade rutas o saca a la lista blanca selectores modal.
Tarea 5: Confirmar el orden DOM del checkbox y el modal para el selector ~
cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("modal-checkbox.html").read()
s=BeautifulSoup(html,"html.parser")
toggle=s.select_one("input.modal-toggle")
modal=s.select_one("div.modal")
print("toggle before modal:", toggle.sourceline < modal.sourceline)
PY
toggle before modal: True
Qué significa: el selector de hermano general puede aplicarse.
Decisión: si el toggle viene después del modal, el CSS nunca coincidirá. Mueve el toggle antes o cambia de patrón.
Tarea 6: Detectar disparadores de contexto de apilamiento en CSS
cr0x@server:~$ rg -n "transform:|filter:|backdrop-filter:|opacity:" src/styles/
src/styles/layout.css:44:transform: translateZ(0);
src/styles/header.css:12:position: sticky;
src/styles/header.css:13:z-index: 2000;
Qué significa: hay transforms y filters presentes; los headers sticky probablemente crean capas de apilamiento.
Decisión: si el modal está anidado bajo un contenedor transformado, mueve el punto de montaje del modal o elimina el truco del transform.
Tarea 7: Validar que el backdrop cubre el viewport
cr0x@server:~$ node - <<'NODE'
const css = `
.modal__backdrop { position:absolute; inset:0; }
`;
console.log(css.includes("inset:0") ? "OK: inset present" : "BAD: missing inset");
NODE
OK: inset present
Qué significa: se usa el dimensionado más simple y robusto.
Decisión: si falta inset, añádelo; no intentes “width:100%; height:100%” en contextos anidados y luego preguntes por qué falla.
Tarea 8: Buscar pointer-events: none accidental en overlay/backdrop
cr0x@server:~$ rg -n "pointer-events:\s*none" src/styles/
src/styles/utilities.css:88:.no-pointer{pointer-events:none}
Qué significa: hay una utilidad que podría aplicarse por accidente.
Decisión: si tu backdrop hereda una clase “no-pointer” por composición de componentes, corrige la composición de clases; no parchees añadiendo más z-index.
Tarea 9: Reproducir el problema de historial con :target
cr0x@server:~$ cat <<'TXT'
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)
TXT
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)
Qué significa: esto no es un bug; es el diseño de la navegación por fragmentos.
Decisión: si esa UX es inaceptable, deja de usar :target aquí.
Tarea 10: Confirmar que la CSP de producción no bloquea estilos inline (si los usaste)
cr0x@server:~$ curl -I https://example.test/page.html | sed -n '1,20p'
HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; style-src 'self'
Qué significa: los bloques <style> inline pueden bloquearse si no se permite 'unsafe-inline'.
Decisión: mueve el CSS del modal a una hoja servida o actualiza la CSP. No publiques “funciona en dev” con CSS inline en un entorno CSP estricto.
Tarea 11: Detectar cambios de diseño causados por escalado de fuentes
cr0x@server:~$ python3 - <<'PY'
base=16
scaled=20
close_btn=24
print("close button px at base:", close_btn)
print("close button px relative to font scaling:", close_btn*(scaled/base))
PY
close button px at base: 24
close button px relative to font scaling: 30.0
Qué significa: los cambios de escala de fuente afectan objetivos y diseño. Si dimensionaste con márgenes en vh, el diálogo puede moverse.
Decisión: dimensiona el diálogo con max-width/max-height y desplazamiento interno, no con márgenes frágiles de viewport.
Tarea 12: Comprobar IDs duplicados (un asesino silencioso de :target)
cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
ids={}
dups=[]
for el in s.select("[id]"):
i=el["id"]
ids[i]=ids.get(i,0)+1
for i,c in ids.items():
if c>1:
dups.append((i,c))
print("duplicates:", dups[:10])
PY
duplicates: []
Qué significa: no se detectaron IDs duplicados.
Decisión: si existen duplicados, :target puede apuntar al elemento equivocado o comportarse de forma inconsistente. Arregla los IDs primero; no toques CSS hasta que sean únicos.
Tarea 13: Asegurar que el modal esté montado al final del body (para evitar padres transformados)
cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
body=s.body
last_tags=[t.name for t in body.find_all(recursive=False)][-5:]
print("last top-level body children:", last_tags)
PY
last top-level body children: ['footer', 'div', 'script', 'script', 'script']
Qué significa: hay un div cerca del final del body que podría ser la raíz de tu overlay.
Decisión: monta los modales como hermanos a nivel de body, no dentro de wrappers de layout transformados.
Tarea 14: Medir el impacto en tamaño de CSS de backdrops elegantes
cr0x@server:~$ ls -lh dist/assets/app.min.css
-rw-r--r-- 1 www-data www-data 182K Dec 12 09:14 dist/assets/app.min.css
Qué significa: el tamaño de la hoja de estilos es moderado; pero esto no mide el coste de paint en tiempo de ejecución.
Decisión: si añades múltiples variantes de blur y animaciones, considera un solo estilo de backdrop. No pagues el impuesto de rendimiento por decoración en flujos críticos.
Tarea 15: Comprobación de cordura de la especificidad del selector abrir/cerrar
cr0x@server:~$ rg -n "\.modal\s*\{|\:target|\:checked" dist/assets/app.min.css | sed -n '1,20p'
1:.modal{position:fixed;inset:0;display:none;z-index:1000}
1:.modal:target{display:block}
1:.modal-toggle:checked~.modal{display:block}
Qué significa: las reglas de apertura están presentes y son directas.
Decisión: si también ves reglas posteriores como .modal{display:none!important}, elimínalas o escópalas. No luches con !important a menos que disfrutes depurando a oscuras.
Tres microhistorias corporativas de las minas de modales
Incidente: la suposición equivocada sobre el hash
Un equipo lanzó un modal solo-CSS :target para “detalles rápidos del producto” en una tienda. No se permitía JS en esa ruta por una iniciativa de rendimiento y una cola de revisión de seguridad. El modal se veía limpio y pasó la QA básica.
Dos semanas después, los tickets de soporte empezaron a acumularse: los usuarios acababan en “estados en blanco” y el botón Atrás se sentía roto. En móvil era peor. El incidente no fue “sitio caído”, pero fue una pérdida de conversión, que en términos corporativos es un tipo de incidente que notan personas que no saben qué es CSS.
La suposición equivocada: creyeron que los fragmentos hash eran “estado UI local” que no tocaba la navegación. En realidad, cada abrir y cerrar mutaba el fragmento de la URL, creando entradas en el historial. Usuarios que abrían detalles, los cerraban y luego presionaban Atrás no volvían a la página de categoría. Reabrían el modal. Presionaban Atrás otra vez y a veces el navegador hacía scroll a un ancla cerca del top donde el contenedor modal vivía en el DOM. Ahora el usuario está perdido y molesto.
La solución no fue ingeniosa. Reemplazaron :target por el patrón de checkbox para esa página y eliminaron por completo los enlaces “#close”. Eliminó el churn de historial. También acortaron el contenido del modal, reduciendo la tentación de navegar hacia adelante y atrás.
Conclusión del postmortem: si tu estado UI cambia la URL, es navegación. Trátalo como navegación. Revísalo como navegación.
Optimización que salió mal: el blur que derritió teléfonos
Otra organización quiso la “sensación premium” de un modal con vidrio esmerilado. Diseño entregó un spec con un fuerte efecto de blur en el backdrop y una animación sutil al abrir. Se veía fantástico en el navegador de escritorio usado para aprobaciones. También se veía fantástico en la presentación donde se tomó la decisión.
El equipo implementó backdrop-filter: blur(14px) con una capa de opacidad y una transición. En pruebas tempranas todo iba bien. Luego llegó a una página real: muchas imágenes, un header sticky y un carrusel haciendo su propia cosa. En Android de gama media y iPhones más antiguos, abrir el modal provocó jank. A veces la página se congelaba un momento. A veces los toques no se registraban. Soporte lo llamó “inactividad intermitente”, la clase de bug más molesta porque no se reproduce a voluntad.
La “optimización” fue empujar más trabajo a la GPU: añadir transforms para promover capas, forzar compositing. Eso creó contextos de apilamiento extra y a veces el modal se renderizaba debajo del header sticky. Ahora el botón de cerrar quedaba parcialmente cubierto en algunos layouts. Genial.
Retrocedieron el blur y usaron un overlay rgba simple. Mantuvieron una transición muy sutil de fade en opacidad solamente. El rendimiento se estabilizó. La sensación premium fue reemplazada por “funciona”, que es la característica premium más valiosa que puede tener un modal.
Broma #2: Cada vez que añades un filtro de blur a un modal, una GPU móvil presenta una queja formal.
Práctica aburrida pero correcta que salvó el día: una raíz de overlay, un contrato
Un tercer equipo gestionaba un sitio con mucho contenido y docenas de componentes aportados por múltiples squads. Ya habían sufrido guerras de z-index y contextos de apilamiento antes, así que establecieron una regla aburrida: cualquier overlay debe montarse en una sola “raíz de overlay” a nivel superior situada al final de <body>.
Sonaba burocrático. La gente resopló porque significaba que no podías simplemente dejar un componente modal donde fuera y darlo por hecho. Pero tuvo su recompensa: apilamiento predecible, posicionamiento predecible y menos sorpresas de “¿por qué esto está debajo del header?”.
Cuando necesitaron un modal solo-CSS para un micrositio de docs (no se permitía JS por restricciones de plataforma), usaron la misma raíz de overlay. Evitó ancestros transformados porque la raíz de overlay estaba fuera de los wrappers de layout. Su backdrop siempre cubría el viewport. Su z-index fue estable porque se definió una vez.
Más tarde, cuando introdujeron una barra promo sticky con una animación basada en transform (un generador clásico de contexto de apilamiento), no rompió el modal. La raíz de overlay seguía por encima. No hubo parche de emergencia, no hubo deploy un viernes, no hubo culpas entre equipos.
La práctica no era ingeniosa. Era un contrato. Los contratos previenen incidentes.
Listas de verificación / plan paso a paso
Checklist: elegir el patrón CSS-only correcto
- Si existe enrutamiento por hash (SPA, analytics, anclas en página muy usadas): prefiere checkbox (
:checked). - Si el modal debe ser enlazable y el comportamiento de historial es aceptable:
:targetestá bien. - Si el modal contiene un formulario o acción crítica del usuario: no uses solo CSS. Usa JS o
<dialog>. - Si necesitas Escape-para-cerrar: no uses solo CSS.
Checklist: un modal solo-CSS que no te avergüence
- La raíz del modal usa
position: fixed; inset: 0;. - El backdrop es un elemento de pantalla completa y intercepta eventos de puntero.
- El diálogo usa
max-widthymax-height; el contenido se desplaza internamente. - El botón de cierre tiene un objetivo de clic grande y una etiqueta ARIA.
- El cierre por backdrop está activado solo para contenido no destructivo.
- El modal se monta al final del body o fuera de ancestros transformados.
- No dependas de una carrera de
z-index. Un contexto de apilamiento, un número. - Prueba con zoom 200% y aumento de tamaño de fuente.
Paso a paso: construir un modal :target con navegación sensata
- Crea un enlace disparador apuntando a
#modal-id. - Crea un contenedor modal
<div id="modal-id" class="modal">cerca del final del body. - Añade un backdrop que sea un enlace apuntando a un fragmento neutral (comúnmente
#close), más un enlace de cierre dentro del diálogo. - Estila
.modalcomo oculto por defecto; revela con.modal:target. - Decide explícitamente si el comportamiento de historial es aceptable. Si no, detente y elige checkbox o JS.
Paso a paso: construir un modal con checkbox que sobreviva refactors
- Coloca
<input type="checkbox" class="modal-toggle">directamente antes del modal en el DOM. - El control de apertura es
<label for="... ">; el control de cierre es otro label. - Usa
.modal-toggle:checked ~ .modalpara mostrarlo. - Documenta el requisito de orden del DOM en el README del componente y comentarios de código.
- Añade una prueba unitaria o una comprobación estática que falle si la estructura cambia (sí, incluso para componentes solo-CSS).
Preguntas frecuentes
¿Puede un modal solo con CSS ser totalmente accesible?
No. Sin JS no puedes atrapar el foco de forma fiable, restaurarlo ni implementar Escape-para-cerrar. Aun así puedes minimizar el daño con roles, etiquetas y controles visibles adecuados.
¿Debería usar :target o :checked?
Si quieres deep links y toleras efectos de historial, :target. Si estás en una app con router pesado o te importa el comportamiento del botón Atrás, :checked.
¿Por qué mi modal aparece detrás de un header sticky incluso con un z-index enorme?
Porque el z-index no cruza fronteras de contexto de apilamiento. Un padre con transform o filter puede atrapar tu modal en un contexto inferior.
¿Cómo prevengo el scroll de fondo sin JS?
A veces puedes usar :has() para alternar overflow: hidden en html. Sin :has(), tu mejor opción es hacer que el diálogo tenga scroll interno y reducir el encadenamiento de scroll.
¿Cerrar al hacer clic fuera siempre es buena idea?
No. Está bien para modales informativos e imágenes. Es arriesgado para formularios o confirmaciones porque un clic accidental puede descartar trabajo del usuario.
¿Por qué abrir un modal :target a veces desplaza la página?
La navegación por fragmentos puede desplazar hasta el elemento objetivo. El posicionamiento fijo suele enmascararlo, pero la colocación en el DOM y el comportamiento del navegador aún pueden causar saltos.
¿Puedo apilar múltiples modales solo con CSS?
Puedes, pero es frágil. :target solo apunta a un fragmento a la vez. Los modales con checkbox pueden apilarse, pero la gestión del foco y del z-index se complica rápido.
¿Y usar <dialog> en su lugar?
Si se permite JS siquiera un poco, <dialog> es un mejor primitivo que los hacks de CSS. Sigue requiriendo decisiones cuidadosas de UX, pero partes más cerca de la semántica correcta.
¿Necesito atributos ARIA si es “solo CSS”?
Si presentas algo como un diálogo, sí: role="dialog", aria-modal="true" y aria-labelledby son la base. No resuelven el foco, pero reducen confusión.
Siguientes pasos que realmente puedes hacer
- Decide si estás construyendo un modal de aplicación real o una superposición de contenido. Si es UI real de aplicación, para y presupuestiza JS mínimo.
- Escoge un patrón solo-CSS y estandarízalo. Mezclar patrones en un sitio multiplica modos de fallo.
- Establece una raíz de overlay cerca del final del body. Es aburrido. Evita melodramas de z-index.
- Escribe las restricciones. Si usas el patrón checkbox, documenta el requisito de orden del DOM como un contrato.
- Prueba como pesimista: 200% de zoom, fuentes grandes, Safari móvil y una página con headers sticky y transforms. Si lo sobrevive, sobrevivirá a tus usuarios.
Si recuerdas una cosa: los modales solo con CSS son aceptables cuando son pequeños, predecibles y honestos sobre sus limitaciones. En el momento en que necesites corrección orientada al teclado, usa la herramienta adecuada. Tu canal de incidentes futuro te lo agradecerá.