Mega menú con CSS Grid: hover, focus, móvil y conceptos básicos de accesibilidad
Tu equipo de marketing quiere un “simple” mega menú. Luego un usuario de teclado no puede alcanzar la mitad de los enlaces, los toques en móvil abren y cierran como un ascensor averiado,
y alguien reporta un bug titulado “la navegación desaparece si respiro cerca”.
Así es como construyes un mega menú que se comporta como adulto: hover y focus predecibles, comportamiento móvil sensato y fundamentos de accesibilidad que no te avergüenzan en una auditoría.
Qué es un mega menú (y qué no es)
Un mega menú es un patrón de navegación donde un elemento de primer nivel abre un gran panel que contiene múltiples grupos de enlaces—a menudo dispuestos en columnas,
a veces con contenido destacado, encabezados, iconos, promociones o accesos directos “populares”. La palabra clave es grupo. Un mega menú existe para exponer
muchos destinos sin enviar al usuario a un laberinto de clics.
Qué no es: un vertedero para cada URL que tu organización haya creado. Si tu mega menú parece una exportación CSV, el problema es la arquitectura de la información,
no el CSS.
Además: un mega menú no es un “menu” en el sentido de ARIA con role="menu" a menos que estés construyendo widgets de menú estilo aplicación.
La mayoría de los sitios quieren navegación simple: listas de enlaces en un <nav>, con un botón o enlace de divulgación que expanda un panel.
No hagas que los usuarios de tecnologías asistivas finjan que están dentro de una aplicación de escritorio.
Regla con opinión
Si tu panel mega necesita más de un scroll en una ventana de laptop, ya no es un mega menú. Es un sitemap con un disparador hover.
Arregla la taxonomía o añade una página “Todos los productos”.
Datos interesantes y contexto histórico
Los mega menús no aparecieron porque los diseñadores se despertaran colectivamente y eligieran complejidad. Son una respuesta a la escala: más secciones, más líneas de producto,
más adquisiciones y la fantasía continua de que la navegación puede compensar la entropía organizacional.
aria-expanded para controles de divulgación (botones que abren paneles) es uno de los atributos de mayor señal que puedes añadir para usuarios de lectores de pantalla. Les dice lo que pasó.:focus-within (usable ampliamente desde finales de los 2010) dio a CSS una forma práctica de mantener desplegados los paneles mientras el foco está dentro, sin JS.prefers-reduced-motion (adopción alrededor de 2019) convirtió “menús animados por todas partes” en una conversación de accesibilidad, no solo de rendimiento.inert ahora tiene soporte lo bastante amplio para ser útil para desactivar contenido de fondo durante overlays, pero aún no es excusa para saltarse las pruebas de teclado.
Esa cita es sobre sistemas distribuidos, pero aplica también a componentes UI. Tu mega menú falla en un dispositivo que no probaste, con un modo de entrada que olvidaste,
en un idioma que hizo que tus columnas se quiebren, detrás de un banner que cambió tu contexto de apilamiento. Planifica fallos. Diseña la ruta aburrida.
Arquitectura: primero marcado, después CSS Grid
La forma más rápida de construir un mega menú inaccesible es empezar por CSS. La segunda forma más rápida es empezar por JavaScript. Empieza por la semántica:
una lista de enlaces. Luego añade un control de divulgación para revelar más enlaces. Luego dale estilo. Luego añade solo el JS necesario para manejar móvil y estado.
El marcado que realmente quieres
Para un mega menú de sitio web, normalmente quieres esta estructura:
<nav aria-label="Primary">para la región.<ul><li>para los elementos de primer nivel.- Un
<button>para alternar un panel (mejor para comportamiento de abrir/cerrar), o un enlace si navega. - Un contenedor de panel (a menudo un
<div>) que contiene encabezados y grupos de enlaces.
Si el elemento de primer nivel tanto navega como abre un panel, elige un comportamiento. “Hace ambas cosas” se convierte en “no hace nada” para usuarios de teclado.
La compensación común: elemento superior es un enlace, más un botón de divulgación separado junto a él.
Propiedad del estado: CSS para hover/focus, JS para tap
Usa CSS para:
- Abrir con hover en punteros finos (
@media (hover:hover)) - Mantener abierto mientras el foco esté dentro (
:focus-within)
Usa JavaScript para:
- Alternar abrir/cerrar con tap/clic en pantallas pequeñas
- Establecer
aria-expandedy opcionalmentehidden - Cerrar con Escape
- Cerrar al hacer clic fuera (con cuidado; no consumas clics legítimos)
Exactamente un chiste corto (1/2)
Si tu menú requiere una máquina de estados de 200 líneas, felicidades: has construido un sistema distribuido, pero para la desilusión.
Comportamiento hover y focus que no parpadea
Hover es una conveniencia, no una base. Debe ser aditivo: agradable para usuarios de ratón, irrelevante para táctiles y nunca la única forma de revelar enlaces.
Los dos bugs que vas a enviar si no tienes cuidado:
- Parpadeo: el menú se abre y luego se cierra mientras el cursor se mueve.
- Pérdida de foco: usuarios de teclado hacen tab al disparador, el panel se abre y luego colapsa cuando el foco se mueve al panel.
Arregla la pérdida de foco con :focus-within
El selector CSS más valioso en todo este proyecto es :focus-within. Aplícalo al elemento li que contiene tanto el disparador como el panel.
Cuando cualquier descendiente tiene foco, el panel se mantiene abierto.
En la demo arriba, esta regla hace el trabajo:
cr0x@server:~$ cat snippets/nav.css | sed -n '1,18p'
.nav > ul > li:hover > .panel,
.nav > ul > li:focus-within > .panel{
display:block;
}
.panel::before{
content:"";
position:absolute;
left: 0;
top: -10px;
height: 10px;
width: 100%;
}
El puente ::before es a la vieja escuela, y funciona. Crea una “zona segura” para que el cursor pueda moverse del disparador al panel sin salir del área de hover.
Sin ello, recibirás reports de “el menú desaparece cuando intento usarlo”. Esos informes son exactos.
No abras con hover en táctiles
Los navegadores táctiles a veces emulan hover de maneras que son agradables solo para nadie. Usa media queries para encerrar el comportamiento hover:
@media (hover:hover) and (pointer:fine)→ permitir comportamiento hover.@media (hover:none)→ confiar en alternancia explícita.
Hacks de temporización: evita salvo si es necesario
El enfoque clásico es añadir un retraso de cierre (por ejemplo, 150ms) para que una pequeña deriva del puntero no cierre el menú de golpe. Se siente bien… hasta que no.
Los retrasos pueden hacer que la UI parezca pegajosa e introducir la clase de inestabilidad que la automatización de pruebas adora amplificar.
Prefiere arreglos geométricos (relleno puente, colocación sensata del panel, tamaño adecuado del disparador) sobre temporizadores. Los temporizadores son el último recurso.
Comportamiento en móvil: tocar, desplazarse y “por favor no me atrapes”
El móvil es donde los mega menús van a morir. No porque sea imposible, sino porque los equipos intentan preservar el comportamiento de escritorio en lugar de encontrar un punto medio con la plataforma.
En móvil, un mega menú suele ser:
- un acordeón colapsable dentro de un cajón de navegación, o
- una lista apilada donde tocar una sección revela enlaces agrupados en línea.
Elige un modelo y comprométete
Aquí tienes dos modelos que funcionan en producción:
| Modelo | Qué se siente | Cuándo usarlo |
|---|---|---|
| Acordeón en línea | Las secciones se expanden hacia abajo en la página; sin overlay. | Cuando la navegación superior ya está en flujo y quieres cero drama de bloqueo de desplazamiento. |
| Cajón de navegación + acordeón | El hamburger abre un cajón; las secciones se expanden dentro. | Cuando necesitas espacio y quieres ocultar la complejidad. |
Bloqueo de desplazamiento: la pistola en el pie más popular
Si usas un overlay/cajón, podrías bloquear el scroll del body. Hazlo con cuidado o activarás bugs de iOS Safari, comportamiento roto de “volver arriba”
o saltos extraños al cerrar. Si tu nav no requiere estrictamente un overlay, evita el bloqueo de desplazamiento por completo. El mejor bloqueo de desplazamiento es el que no implementaste.
Comportamientos de cierre que respetan a las personas
- Escape cierra el panel/cajón abierto.
- Tap fuera lo cierra (pero solo cuando es un overlay; los acordeones en línea no deberían colapsar porque tocaste en otra parte).
- El foco debe moverse al contenido abierto si abres un cajón, y volver al disparador al cerrarlo.
Reducción de movimiento y rendimiento
Si animas el panel, mantenlo sutil y rápido. Y encierra la animación:
- Respeta
prefers-reduced-motion: reduce. - Evita animar propiedades de layout que causen reflow (como height desde auto). Prefiere opacity y transform si debes animar.
Exactamente un chiste corto (2/2)
“Solo añade un desenfoque detrás del menú” es cómo conviertes un problema de navegación en un programa de benchmarking de GPU.
Fundamentos de accesibilidad: roles, etiquetas y expectativas
La accesibilidad no es un estado de ánimo. Es un contrato: los usuarios de teclado deben alcanzar todo, los lectores de pantalla deben entender los cambios de estado, y cualquiera debe poder salir.
“Funciona en mi trackpad” no es una prueba aprobatoria.
Usa semántica de navegación, no menús de aplicación
Para un encabezado de sitio, mantente con:
<nav aria-label="Primary"><ul><li><a>para enlaces<button aria-expanded aria-controls>para la divulgación
Evita role="menu" a menos que estés construyendo un widget de menú real con navegación por flechas y roles de menuitem. Si añades roles de menu, heredas esas reglas de interacción.
La mayoría de los equipos añade los roles y omite los comportamientos. Eso es peor que no hacer nada.
Botones de divulgación: ARIA mínimo viable
Un botón que abre un panel debería tener:
aria-expanded="false|true"reflejando el estadoaria-controls="panel-id"apuntando al elemento del panel- Un nombre accesible (“Productos”, no “Chevron”)
El panel en sí puede ser un contenedor simple. Si siempre está en el DOM, puedes usar hidden cuando esté colapsado, lo que lo elimina del árbol de accesibilidad y del orden de tabulación.
Evita estados “visualmente oculto pero todavía enfocables”; son la forma de que usuarios de teclado hagan tab hacia el vacío.
Gestión del foco: cómo se siente “bien”
Para menús hover/focus de escritorio:
- Tab hasta el disparador: el panel se abre.
- Tab avanza hacia los enlaces del panel: el panel permanece abierto.
- Shift+Tab vuelve al disparador: el panel permanece abierto hasta que el foco salga de todo el componente.
Para cajones móviles:
- Abrir el cajón mueve el foco al primer elemento enfocables dentro.
- Cerrar devuelve el foco al botón hamburger.
- El contenido de fondo no debe ser enfocables mientras el cajón está abierto (usa
inerto una trampa de foco, pero impleméntalo correctamente).
Objetivos táctiles y espaciado
Los encabezados y enlaces de tu mega menú no deben ser minúsculos. La física del dedo gordo es invencible. Da padding a los enlaces; no es espacio desperdiciado, es margen de error.
Además, no pongas múltiples controles diminutos (enlace + cheurón + badge) dentro de una fila de 32px de alto y lo llames “limpio”.
Patrones de diseño CSS Grid para paneles mega
Grid es la herramienta adecuada porque los paneles mega son diseños bidimensionales: columnas de grupos, a veces con bloques destacados, a veces con imágenes.
Flexbox está bien para alineación unidimensional; se vuelve cinta adhesiva cuando necesitas columnas estables que no colapsen en espagueti a determinados anchos.
Patrón 1: columna destacada fija + columnas de enlaces fluidas
Un patrón común: un bloque “destacado” (descripción, CTA, quizá una imagen) y dos columnas de enlaces. Usa:
grid-template-columns: 1.4fr 1fr 1fren escritorio- colapsar a
1fren móvil
Patrón 2: auto-fit para número desconocido de grupos
Si tu CMS puede emitir 3–8 grupos y no lo controlas estrictamente (un olor, pero común), usa:
repeat(auto-fit, minmax(180px, 1fr)).
Esto hace que las columnas se envuelvan limpiamente sin forzarte a codificar puntos de quiebre para cada variación de contenido. No es magia: los encabezados largos todavía se envolverán.
Pero degrada como un profesional, no como una sorpresa.
Patrón 3: mantener encabezados con sus primeros enlaces
El bug de diseño furtivo: un encabezado de grupo al final de una columna y sus enlaces en la parte superior de la siguiente después del wrapping. Solución: hacer que cada grupo sea un solo elemento grid:
el encabezado y su lista pertenecen al mismo contenedor.
Contextos de apilamiento y el problema “por qué está detrás del encabezado”
Los mega menús a menudo “desaparecen” detrás de banners, encabezados sticky o secciones hero. Normalmente no es solo z-index; son contextos de apilamiento creados por:
position+z-indexen ancestrostransformen ancestros (crea un nuevo contexto de apilamiento)filter,opacity,mix-blend-modede manera similar
Cuando depures esto, no aumentes z-index a 999999 al azar. Así obtienes un sitio donde todo está encima de todo, para siempre.
Encuentra el contexto de apilamiento y arregla la raíz.
Tareas prácticas: comandos, salida y decisiones
El menú es frontend, pero entregarlo de forma fiable sigue siendo trabajo de sistemas: prueba, mide, monitoriza y no te fíes solo de tus ojos.
Abajo hay tareas prácticas que puedes ejecutar localmente o en CI para atrapar los desastres habituales. Cada tarea incluye un comando, lo que significa la salida y qué decisión tomar después.
Tarea 1: Confirmar objetivos de soporte de navegador (sanidad base)
cr0x@server:~$ cat package.json | jq '.browserslist'
[
"defaults",
"not IE 11",
"maintained node versions"
]
Significado de la salida: No estás reclamando soporte para IE 11. Bien; Grid y selectores modernos no necesitarán hacks.
Decisión: Si IE 11 debe ser soportado (raro pero no extinguido), detente y rediseña: no estás construyendo el mismo mega menú.
Tarea 2: Ejecutar una compilación local y asegurar que el CSS se entregue
cr0x@server:~$ npm run build
> web@1.0.0 build
> vite build
vite v5.0.0 building for production...
dist/assets/index-3f2c7f1a.css 42.31 kB │ gzip: 8.90 kB
dist/assets/index-b0d1c8ad.js 182.12 kB │ gzip: 58.70 kB
✓ built in 2.54s
Significado de la salida: Existe CSS, no es sospechosamente pequeño y se está incluyendo en el bundle.
Decisión: Si falta CSS o es muy pequeño, revisa tu pipeline de build por purgado/tree-shaking que elimine estilos de navegación (común cuando los nombres de clase se generan).
Tarea 3: Detectar eliminación accidental de estilos de foco
cr0x@server:~$ rg -n "outline:\s*none" dist/assets/index-*.css | head
1221:.nav-link:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(122,162,255,.45);background:rgba(255,255,255,.06)}
Significado de la salida: El outline se remueve pero se reemplaza con un indicador de foco visible (box-shadow).
Decisión: Si encuentras outline:none sin un reemplazo, rechaza el cambio. Usuarios de teclado reportarán bugs incontestables.
Tarea 4: Confirmar que el panel no es enfocables cuando está “cerrado” (auditoría DOM)
cr0x@server:~$ node -e "const {JSDOM}=require('jsdom'); const html=require('fs').readFileSync('dist/index.html','utf8'); const d=new JSDOM(html).window.document; console.log(d.querySelectorAll('.panel a').length);"
18
Significado de la salida: Los enlaces existen en el panel; ahora asegúrate de que tu runtime use hidden o renderizado condicional cuando esté cerrado.
Decisión: Si los paneles siempre están accesibles en el orden de tabulación cuando están cerrados, implementa el alternado hidden en JS para móvil/alternadores explícitos.
Tarea 5: Lint para desajustes de atributos ARIA (ganancia barata en CI)
cr0x@server:~$ npx eslint src/nav/**/*.tsx
src/nav/MegaMenu.tsx
88:17 error aria-controls value must match an element id jsx-a11y/aria-props
✖ 1 problem (1 error, 0 warnings)
Significado de la salida: Un control referencia un id de panel que no existe (o cambia por renderizado).
Decisión: Arregla los IDs para que sean estables y únicos. Nunca publiques un aria-controls que apunte a ninguna parte; es una promesa falsa.
Tarea 6: Ejecutar Lighthouse localmente y leer las sugerencias relacionadas con nav
cr0x@server:~$ npx lighthouse http://localhost:4173 --only-categories=accessibility,performance --output=text
Performance: 86
Accessibility: 94
Diagnostics:
Avoid enormous network payloads (main-thread impact)
Accessibility audits:
Buttons do not have an accessible name (1)
Significado de la salida: Un botón no tiene nombre accesible (a menudo el botón cheurón de divulgación).
Decisión: Añade un nombre accesible vía texto, aria-label o aria-labelledby. No publiques botones solo icono sin etiquetas.
Tarea 7: Ejecutar axe contra la página (señal de a11y más precisa)
cr0x@server:~$ npx @axe-core/cli http://localhost:4173 --tags wcag2a,wcag2aa
Running axe-core 4.x
Violations:
1) aria-required-attr: Required ARIA attributes must be provided
- .menu-toggle (aria-expanded missing)
Significado de la salida: Tu control de divulgación carece de estado requerido.
Decisión: Añade aria-expanded y actualízalo al alternar. Esto no es opcional si tienes una región colapsable.
Tarea 8: Verificar que el comportamiento hover está limitado a dispositivos con pointer/hover
cr0x@server:~$ rg -n "@media\s*\\(hover:hover\\)" src/styles/nav.css
148:@media (hover:hover) and (pointer:fine){
Significado de la salida: Estás encapsulando explícitamente reglas hover.
Decisión: Si las reglas hover son globales, tendrás rarezas en táctil. Envuélvelas y luego implementa clic-para-alternar en pantallas pequeñas.
Tarea 9: Detectar cambios de diseño cuando se abre el menú (prueba CLS)
cr0x@server:~$ npx playwright test -g "mega menu does not shift layout"
Running 1 test using 1 worker
✓ 1 [chromium] › nav.spec.ts:14:1 › mega menu does not shift layout (2.3s)
Significado de la salida: Tu prueba afirma que la altura del encabezado no cambia y el contenido no salta cuando se abren los paneles.
Decisión: Si falla, prefiere paneles posicionados absolutamente en escritorio (overlay) o reserva espacio intencionalmente. No dejes que todo el sitio refluya al hacer hover.
Tarea 10: Inspeccionar problemas de contexto de apilamiento con estilos calculados (triatge de z-index)
cr0x@server:~$ node -e "console.log('Check in DevTools: does any ancestor have transform/filter/opacity < 1? If yes, you created a stacking context.')"
Check in DevTools: does any ancestor have transform/filter/opacity < 1? If yes, you created a stacking context.
Significado de la salida: Esta es una tarea recordatorio, no automatizable. Los contextos de apilamiento se depuran más fácil visualmente en DevTools.
Decisión: Si el panel está detrás de algo, elimina el transform/filter del ancestro o mueve el panel a un contenedor portal de nivel superior.
Tarea 11: Confirmar que activos clave no bloquean la primera interacción (prueba TTI)
cr0x@server:~$ npx webpack-bundle-analyzer dist/stats.json
Webpack Bundle Analyzer is started at http://127.0.0.1:8888
Use Ctrl+C to close it
Significado de la salida: Puedes ver visualmente si el código del mega menú cargó un chunk del tamaño de una pequeña luna.
Decisión: Si el menú cuesta demasiado JS, refactoriza: CSS para interacción de escritorio, JS mínimo para toggles y difiere analytics no críticos en el encabezado.
Tarea 12: Verificar que los toggles responden a Escape (prueba de comportamiento)
cr0x@server:~$ npx playwright test -g "escape closes open mega menu"
Running 1 test using 1 worker
✓ 1 [chromium] › nav.spec.ts:41:1 › escape closes open mega menu (1.8s)
Significado de la salida: Escape cierra el panel abierto y devuelve el foco apropiadamente (tu prueba debe asertar el foco).
Decisión: Si falla, implementa manejo de keydown en el botón de divulgación/contenedor del panel y asegúrate de restaurar el foco.
Tarea 13: Detectar elementos enfocables faltantes en el panel abierto (auditoría del orden de tabulación)
cr0x@server:~$ npx playwright test -g "tab reaches first link in opened panel"
Running 1 test using 1 worker
✘ 1 [chromium] › nav.spec.ts:62:1 › tab reaches first link in opened panel (2.0s)
Error: expected "body" to match /a.panel-link/
Significado de la salida: Tras abrir, tab no se movió al panel; el foco cayó al body o a un elemento fuera del menú.
Decisión: Comprueba si el panel tiene display:none en el momento equivocado, o si un focus trap global roba el foco.
Tarea 14: Confirmar que CSS Grid está aplicado realmente (no sobrescrito)
cr0x@server:~$ rg -n "display:\s*grid" dist/assets/index-*.css | head -n 3
1304:.panel-inner{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:14px}
Significado de la salida: Los estilos Grid existen en el output construido.
Decisión: Si falta Grid, podrías estar publicando una hoja de estilo legacy o los estilos del componente están mal scoped.
Tarea 15: Verificar que no hay pointer-events deshabilitados accidentalmente
cr0x@server:~$ rg -n "pointer-events:\s*none" src/styles | head
src/styles/animations.css:22:.no-pointer{pointer-events:none}
Significado de la salida: Tienes una clase que deshabilita pointer events; esto puede aplicarse accidentalmente a paneles u overlays.
Decisión: Asegúrate de que no se use en contenedores de navegación. “El menú no responde al clic” suele ser autoinfligido.
Guía rápida de diagnóstico
Cuando alguien dice “mega menú roto” cinco minutos antes de un release, no tienes tiempo para filosofar. Necesitas un camino corto hacia el cuello de botella.
Aquí está el orden que encuentra la causa raíz más rápido en sistemas reales.
Primero: desajuste de modo de entrada (hover vs tap vs teclado)
- Revisa en un teléfono: ¿tocar abre de forma fiable y se mantiene abierto mientras desplazas?
- Revisa con teclado: tab hasta el disparador, tab al panel; ¿permanece abierto?
- Revisa con trackpad/ratón: ¿mover diagonalmente causa flicker?
Si un modo falla, probablemente vinculaste comportamiento al hover, o no implementaste :focus-within, o falta estado explícito para móvil.
Segundo: visibilidad y contexto de apilamiento (la clase “está ahí pero no lo veo”)
- En DevTools, inspecciona el elemento panel. ¿Está en el DOM? ¿Está en
display:none? - Revisa
z-indexcomputado y si un ancestro crea un contexto de apilamiento (transform,filter). - Busca
overflow:hiddenen wrappers de encabezado que recorten el panel.
Si el panel existe pero está detrás o recortado, no subas z-index. Arregla el contexto de apilamiento o mueve el contenedor del panel.
Tercero: deriva de foco y estado ARIA
- ¿
aria-expandedrefleja el estado visible? - ¿El panel está realmente oculto (usando
hidden) cuando está colapsado? - ¿Hay una trampa de foco aplicada globalmente que bloquea el tab hacia el panel?
Si el estado ARIA miente, usuarios de lectores de pantalla reportarán comportamientos “aleatorios”. No son aleatorios; estás transmitiendo estado incorrecto.
Cuarto: inestabilidad provocada por rendimiento
- ¿Abrir el menú se siente entrecortado por sombras pesadas, filtros de desenfoque o demasiadas imágenes?
- ¿Estás provocando thrash de layout animando height o midiendo el DOM en un bucle?
Si abrir se siente lento, reduce el coste de pintura y evita layouts forzados. Un mega menú debería abrirse como si le diera vergüenza robar tu tiempo.
Errores comunes: síntoma → causa raíz → solución
Estos son los bugs que siguen apareciendo porque los equipos copian patrones sin entender el problema que resolvieron.
Usa esta sección como mapa de diagnóstico.
1) El menú se cierra al mover el cursor al panel
Sintoma: Hover abre, pero el panel desaparece al intentar entrar en él.
Causa raíz: Hay un hueco entre el disparador y el panel; se pierde el estado hover.
Solución: Añade un “puente” con padding o un pseudo-elemento (::before) en el panel; posiciona el panel alineado con el área del disparador.
2) Usuarios de teclado no pueden alcanzar enlaces del panel
Sintoma: Tab abre el panel, pero en cuanto el foco se mueve, el panel se cierra.
Causa raíz: Solo :hover abre el panel; no hay soporte de :focus-within.
Solución: Abre el panel con li:focus-within, no solo con hover. Asegura que el panel sea descendiente del contenedor focus-within.
3) En móvil, al tocar se abre y se cierra inmediatamente
Sintoma: Al tocar se alterna pero el estado se revierte enseguida.
Causa raíz: El manejador de “clic fuera para cerrar” se dispara porque el evento burbujea; o usas un listener de clic en document sin exclusiones.
Solución: Deja de tratar todo como “fuera”. Comprueba la contención con event.target; usa pointerdown en captura con cuidado; ignora el clic del disparador que abrió el panel.
4) El menú aparece detrás del encabezado o hero
Sintoma: El panel está abierto (en DOM) pero invisible o parcialmente oculto.
Causa raíz: Contexto de apilamiento o recorte: un ancestro tiene transform o overflow:hidden.
Solución: Elimina la propiedad ofensiva o renderiza el panel en un portal en la raíz del documento. Luego establece una escala de z-index sensata.
5) El lector de pantalla anuncia “colapsado” cuando está abierto
Sintoma: El estado visual y el estado para asistencias divergen.
Causa raíz: aria-expanded no actualizado, o el panel se muestra vía CSS sin actualizar ARIA.
Solución: Si una acción de usuario alterna la visibilidad, actualiza aria-expanded en la misma ruta de código. Prefiere estado explícito para toggles explícitos.
6) Al tabular se llega a enlaces invisibles
Sintoma: El foco desaparece; el usuario de teclado se pierde.
Causa raíz: El panel está oculto visualmente mediante opacity/transform pero sigue en el orden de tabulación.
Solución: Usa hidden (o renderizado condicional) cuando esté colapsado. Si debes animar, anima desde hidden → visible con una estrategia de transición corta que no lo deje enfocable al estar cerrado.
7) El diseño salta al abrir el menú (CLS)
Sintoma: El contenido se desplaza hacia abajo cuando aparece el panel.
Causa raíz: El panel está en flujo normal (no overlay) en escritorio; abrirlo cambia la altura del encabezado.
Solución: Posiciona el panel de forma absoluta en escritorio, o reserva espacio intencionalmente con una región de encabezado estable. No permitas que el hover refluya la página.
8) El mega menú se convierte en un impuesto de rendimiento
Sintoma: Abrir es entrecortado; FPS baja; batería sufre.
Causa raíz: Desenfoque/backdrop-filter pesado, demasiadas sombras, imágenes grandes o layout caro al abrir.
Solución: Reduce efectos, precarga tamaños de imágenes, evita blur. Si animas, anima opacity/transform. Mide en móvil de gama media.
Listas de comprobación / plan paso a paso
Este es el plan que daría a un equipo que necesita entregar un mega menú sin pasar el siguiente trimestre en triage de bugs.
Es aburrido. Por eso funciona.
Plan de construcción paso a paso (hazlo en este orden)
- Define la IA. Identifica categorías principales y grupos de enlaces. Limita el número de items por grupo; crea páginas “Todos…” para el resto.
- Escribe HTML semántico primero. Nav → ul/li → enlaces. Añade botones de divulgación solo donde exista un panel.
- Implementa reglas de apertura de escritorio en CSS. Usa
:focus-withiny hover limitado por capacidad de pointer. - Implementa el layout del panel con Grid. Construye grupos como items de grid para que los encabezados no se separen de sus enlaces.
- Añade JS mínimo para toggles explícitos. Gestiona
aria-expanded,hidden, Escape y clic fuera (si es overlay). - Decisión de diseño móvil. Acordeón en línea o cajón. No intentes preservar la UX de hover de escritorio.
- Reglas de gestión de foco. Para cajones, mueve el foco dentro y de vuelta; asegura que el fondo sea inert cuando corresponda.
- Comprobaciones de accesibilidad en CI. Axe o reglas eslint que bloqueen merges por regresiones obvias.
- Pruebas de rendimiento rápidas. Evita filtros blur, reduce coste de pintura, vigila el crecimiento de bundles JS.
- Pruebas cruzadas de entrada. Teclado + ratón + táctil. También prueba zoom al 200% y aumento de tamaño de texto.
Lista pre-merge (rápida pero estricta)
- El teclado puede alcanzar cada enlace en cada panel.
- El indicador de foco es visible y consistente.
aria-expandedse actualiza correctamente en toggles.- Los paneles no son enfocables cuando están cerrados (
hiddeno no renderizados). - En táctil, el comportamiento al tocar es determinista (sin rarezas por emulación de hover).
- Escape cierra el estado abierto; el foco vuelve al disparador.
- No hay desplazamiento de diseño al abrir en anchos de escritorio.
- El panel no queda recortado por overflow; z-index es sensato y documentado.
Lista post-despliegue (porque prod es donde vive la verdad)
- Monitoriza errores del lado cliente alrededor del código de toggles de nav.
- Revisa replays de sesión o eventos analytics por bucles repetidos de abrir/cerrar (signo de taps erróneos o objetivos rotos).
- Observa métricas de rendimiento (INP, CLS) tras el despliegue.
- Verifica que banners de cookies, alertas y tests A/B no superpongan la nav de forma extraña.
Tres mini-historias corporativas desde el campo
Mini-historia #1: El incidente causado por una suposición equivocada
Una empresa B2B SaaS desplegó un header rediseñado con un mega menú. El diseñador lo probó en un MacBook con ratón. El ingeniero en modo responsive de Chrome DevTools.
Ambos se sintieron confiados. El lanzamiento salió un martes porque, aparentemente, todos querían aprender algo.
En horas, los tickets de soporte mostraron un patrón: usuarios móviles no podían navegar a subpáginas de precios. El menú “se abría” y luego se cerraba antes de que pudieran tocar algo.
Producto asumió que era “un bug de Safari”. Ingeniería asumió “problema de objetivo táctil”. Marketing asumió “los usuarios son tontos”. Solo una de esas es una hipótesis solucionable.
La causa raíz fue una suposición equivocada: “las reglas hover no importan en móvil”. Tenían CSS que abría paneles con :hover y cerraba en mouseout.
En iOS, el primer toque provocó un estado parecido a hover, luego el listener de clic a nivel de documento interpretó la segunda interacción como clic fuera y cerró el panel.
El componente básicamente se estaba peleando consigo mismo.
La solución fue poco glamurosa: limitar el comportamiento hover con @media (hover:hover) and (pointer:fine), y usar estado de alternancia explícito solo en móvil.
También arreglaron el handler de clic fuera para ignorar la interacción que abrió el panel.
El postmortem fue aún más aburrido: “Probemos en un teléfono real antes de desplegar cambios de navegación.” Esa política sobrevivió porque era fácil de cumplir
y ahorró tiempo a todos.
Mini-historia #2: La optimización que salió mal
Otra organización tenía un mega menú con imágenes y tarjetas promocionales dentro del panel. Alguien notó que abrir el menú era lento en portátiles antiguos.
El equipo hizo lo que hacen los equipos: optimizaron. Decidieron lazy-load todo dentro del panel solo cuando se abre, incluidos grupos de enlaces traídos desde un endpoint del CMS.
En papel, era limpio: no renderices lo que no ves. En la práctica, el panel ahora tenía una dependencia de red en la primera interacción.
La primera apertura tardaba un latido notable; a veces abría vacío y luego se poblaba. Usuarios de teclado hacían tab hacia nada y se quedaban atascados.
Luego vino la falla sutil: el endpoint del CMS era ocasionalmente lento. No caído, solo lo bastante lento. El menú “funcionaba”, pero se volvió impredecible.
Los clientes lo describían como “la navegación es inestable”. Inestable es una forma educada de decir “no confío en vuestro producto”.
Revirtieron el enfoque fetch-on-open y optaron por un plan más simple: enviar grupos de enlaces core en el payload inicial HTML/JSON y lazy-load solo imágenes estrictamente decorativas.
El panel volvió a abrir instantáneamente y la percepción de rendimiento mejoró más que la métrica optimizada originalmente.
Lección: optimizar añadiendo dependencias en tiempo de ejecución a caminos UI críticos es como cachear contraseñas en texto plano porque es más rápido. Sí, es más rápido. No, no puedes hacerlo.
Mini-historia #3: La práctica aburrida pero correcta que salvó el día
Un gran sitio empresarial ejecutaba docenas de experimentos. El header lo “poseía” un equipo de plataforma, pero varios equipos de growth inyectaban banners, promos sticky
y ocasionalmente un widget de chat que insistía en vivir en la esquina superior derecha como una planta agresiva.
El equipo de plataforma mantenía una escala de z-index en CSS y la hacía cumplir en las revisiones de código. También tenían una regla: “Cualquier cosa que overlayee el header debe renderizarse en una
raíz de overlay dedicada, no dentro de secciones aleatorias de la página.” Suena pedante hasta el día en que no lo es.
Un viernes, un experimento de growth añadió una hero con una animación transform sutil. Eso creó un contexto de apilamiento. En muchas páginas, el panel mega apareció detrás
de la hero, haciendo que la navegación pareciera rota. Growth estaba listo para “simplemente poner z-index a un millón”.
El equipo de plataforma no negoció con el caos. Como la raíz de overlay ya era estándar, el panel mega vivía fuera de la hero transformada.
La solución fue una línea en el CSS del experimento para eliminar el transform innecesario del contenedor hero. Nada de carrera de z-index. Ningún incidente de fin de semana.
La práctica aburrida—reglas documentadas de capas y una raíz de overlay dedicada—los salvó de una clase de bugs que de otro modo nunca muere del todo.
Preguntas frecuentes
1) ¿Puedo construir un mega menú solo con CSS?
En escritorio, en su mayoría sí: hover y :focus-within cubren mucho. En móvil, aún querrás JavaScript para gestionar toggles por toque y el estado ARIA.
“Solo CSS” es una demo divertida, no un requisito de producto fiable.
2) ¿El elemento de primer nivel debe ser un enlace o un botón?
Si navega, es un enlace. Si alterna visibilidad, es un botón. Si necesitas ambas cosas, sepáralas: la etiqueta del enlace navega; el botón adyacente alterna.
Comportamiento mixto en un control es donde la UX va a ser auditada.
3) ¿Necesito role="menu" para accesibilidad?
No. Para la navegación del sitio, usa elementos nativos y ARIA para el estado de divulgación (aria-expanded). Añadir roles de menú cambia el comportamiento esperado del teclado
(flechas, roles menuitem). A menos que implementes el patrón completo, no empieces.
4) ¿Cómo evito que el panel se recorte debajo del encabezado?
Busca overflow:hidden en wrappers de encabezado y contextos de apilamiento por transforms. Si no puedes eliminarlos, renderiza el panel en un contenedor de nivel superior
(portal/overlay root) y posiciónalo relativo al disparador.
5) ¿Cuál es la mejor forma de cerrar el menú al hacer clic fuera?
Hazlo solo para overlays/cajones. Para paneles hover de escritorio, las reglas de focus/hover suelen manejar el cierre de forma natural. Si implementas clic fuera,
comprueba panel.contains(event.target) y trigger.contains(event.target) antes de cerrar, y ten cuidado con el orden de eventos.
6) ¿Cuántas columnas debería tener un panel mega?
Empieza con 2–3 columnas en escritorio. Más columnas aumentan el coste de escaneo y hacen los encabezados más pequeños. Usa Grid con colapso responsive; deja que el contenido se envuelva con gracia.
Y si necesitas 6 columnas, probablemente necesites mejor agrupamiento.
7) ¿Por qué mi menú hover “parpadea” solo en diagonales?
Porque el puntero sale del disparador antes de entrar en el panel. Añade un puente de hover (::before), reduce la separación o haz el área del disparador más alta.
Los temporizadores pueden enmascararlo pero a menudo crean nuevos problemas.
8) ¿Debería animar la apertura?
Si puedes abrir instantáneamente, hazlo. Si animas, mantenlo corto, evita animaciones que afecten layout y respeta la reducción de movimiento.
La navegación debe sentirse responsiva, no teatral.
9) ¿Cómo pruebo esto de forma fiable en CI?
Usa Playwright para comportamiento (orden de tab, Escape cierra, toggles táctiles). Usa axe para violaciones de accesibilidad.
Añade una o dos capturas visuales de regresión para el panel abierto en puntos de quiebre clave. Mantén las pruebas estables evitando hacks de temporización.
10) ¿inert es suficiente para accesibilidad de cajones?
Es una herramienta potente, pero aún necesitas gestionar el foco al abrir/cerrar y asegurar que Escape funcione.
También verifica soporte de navegador en tu conjunto objetivo; si no, usa una solución de focus-trap bien probada.
Conclusión: próximos pasos que realmente se entregan
Un mega menú no es un adorno de diseño. Es infraestructura de producción para navegación. Trátalo como tratarías un balanceador de carga: comportamiento predecible, valores por defecto sensatos,
rendimiento medible y corrección aburrida.
Próximos pasos:
- Escribe el HTML semántico y decide comportamiento enlace vs botón por cada ítem de primer nivel.
- Implementa el comportamiento de escritorio con
:focus-withiny hover limitado por capacidad de pointer. - Elige un modelo móvil (acordeón en línea o cajón) e implementa estado de alternancia explícito con el
aria-expandedcorrecto. - Añade al menos tres pruebas Playwright: flujo de tab con teclado, Escape cierra y toggle táctil en móvil.
- Ejecuta Lighthouse y axe, y conecta uno de ellos en CI para no tener regresiones un viernes.
Luego haz el movimiento más subestimado en ingeniería: prueba en un teléfono real. La navegación o funciona o no. La realidad no se preocupa por tu librería de componentes.