Diario
Ingeniería3 min de lectura

El desplegable que se negaba a recibir clics

Un menú se dibujaba perfectamente, se colocaba justo donde debía y se tragaba todos los clics. El culpable era una animación de entrada que en realidad nunca terminaba. Un recorrido corto por los contextos de apilamiento de CSS, los modos de relleno de las animaciones y cómo cazar fallos que no se ven.

EEEl equipo de ingeniería de Bookatu

Lanzamos un menú desplegable en el que no se podía hacer clic. Se abría bien. Se dibujaba en el sitio correcto, totalmente visible, con el z-index correcto y sin errores en la consola. Los clics lo atravesaban y caían en el panel de debajo, como si el menú fuera un fantasma. Si alguna vez has peleado con un fallo así, ya conoces la parte que desorienta: todo se ve bien y nada funciona.

Averiguar quién está encima de verdad

La primera respuesta honesta vino del navegador, no de leer el código. document.elementFromPoint te dice qué elemento recibe realmente un clic en una coordenada. Lo apuntamos al centro de una opción del menú y devolvió una tarjeta que estaba más abajo en la página. El menú estaba visualmente encima y físicamente debajo.

js
// Who really gets the click at this point?const item = document.querySelector('[role="menuitem"]');const r = item.getBoundingClientRect();const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);console.log(top === item || item.contains(top)); // false -> something covers it // Walk the ancestors and list everything that creates a stacking contextfor (let el = item; el; el = el.parentElement) {  const cs = getComputedStyle(el);  const reasons = [];  if (cs.transform !== 'none') reasons.push('transform');  if (cs.filter !== 'none') reasons.push('filter');  if (cs.backdropFilter !== 'none') reasons.push('backdrop-filter');  if (cs.opacity !== '1') reasons.push('opacity');  if (cs.position !== 'static' && cs.zIndex !== 'auto') reasons.push('z-index');  if (reasons.length) console.log(el.className, reasons);}

El recorrido lo encontró. La cabecera que contenía el menú declaraba un transform, aunque ninguna hoja de estilos se lo diera. El único transform que había cerca era una animación de entrada, un pequeño fundido con subida que había terminado medio segundo después de cargar la página.

Los modos de relleno sobreviven a sus animaciones

animation-fill-mode: both significa que el elemento mantiene el primer fotograma clave antes de que empiece la animación y conserva el último cuando termina. Lo conserva para siempre. Si algún fotograma clave anima transform, la animación terminada sigue aplicando un transform, y un elemento con transform se convierte en un contexto de apilamiento. El z-index de todos sus descendientes queda atrapado dentro y solo compite con hermanos locales, nunca con el resto de la página.

Nuestra página tenía paneles detrás de la cabecera que usaban backdrop-filter, que crea su propio contexto de apilamiento. Dos cajas selladas, y el orden de pintado entre ellas decidido por el orden en el árbol y no por el z-index que seguíamos subiendo desconcertados. El menú podría haber tenido un z-index de un millón. Dentro de su caja, nadie de fuera lo oía.

Y aquí está la trampa dentro de la trampa. Cambiamos el último fotograma clave a transform: none, pensando que así el valor retenido sería inofensivo. El estilo calculado seguía informando de una matriz. Los navegadores resuelven el transform de una animación aplicada como un valor de matriz, y una matriz identidad sigue contando como un transform a efectos de apilamiento. El contexto de apilamiento sobrevivió a nuestro arreglo.

css
/* BAD: the finished animation keeps applying its last keyframe forever.   The header remains a stacking context for the life of the page. */.enter {  animation: rise 0.5s ease-out both;} /* GOOD: fill backwards only. The element returns to its natural state   after the animation, which is identical to the final keyframe anyway. */.enter {  animation: rise 0.5s ease-out backwards;} @keyframes rise {  from { opacity: 0; transform: translateY(10px); }  to   { opacity: 1; transform: none; }}

La regla que apuntamos

Las animaciones de entrada sobre contenedores rellenan hacia atrás, nunca en ambos sentidos. El relleno hacia atrás cubre el único hueco que importa, los fotogramas anteriores al arranque de una animación con retardo. Cuando la animación termina, el elemento se desengancha de ella por completo y deja de ser un contexto de apilamiento. El resultado visual es idéntico porque una entrada bien hecha termina en el estado natural del elemento.

Un menú desplegable abriéndose correctamente por encima de paneles de cristal esmerilado en un panel oscuro
Después del arreglo: el menú se pinta por encima de los paneles esmerilados y, lo que más importa, recibe sus propios clics.
  • Fíate de elementFromPoint más que de tus ojos. El orden visual y el orden de detección de clics pueden no coincidir, y solo uno de los dos se lleva el clic.
  • Cuando el z-index deja de tener sentido, deja de subirlo y ponte a buscar contextos de apilamiento. El recorrido por los ascendientes de arriba lleva un minuto.
  • Revisa qué dejan detrás tus animaciones terminadas. getComputedStyle sobre una página en reposo te dice qué sigue aplicado.
  • Un transform retenido es un transform, aunque el fotograma clave diga none.

La animación duraba medio segundo. Su efecto secundario no tenía hora de fin.

contexto de apilamiento cssanimation-fill-modeel z-index no funcionadepurar frontendtransform y apilamiento en css
¿Listo para ponerlo en práctica?

Bookatu te da una página de reservas con tu marca, depósitos, membresías, tarjetas de regalo y recordatorios, con un 0 % de comisión sobre tus reservas.

Empezar gratis