Menú y tooltip anclados sin JavaScript de posicionamiento
El patrón completo de 2026: popover para la capa superior, anchor positioning para la colocación, fallbacks para cuando no cabe, y animación de entrada y salida.
Las dos piezas de los niveles anteriores encajan aquí. popover resuelve la capa: el elemento se pinta por encima de todo, se cierra al pulsar fuera y responde a la tecla de escape. El posicionamiento por anclas resuelve la geometría: el elemento se coloca junto a su botón y se voltea cuando no cabe. Juntas eliminan la biblioteca de posicionamiento y el gestor de superposiciones de una aplicación entera.
- Montar un menú anclado completo y funcional sin JavaScript.
- Aplicar
position-anchor: autousando el ancla implícita del invocador. - Animar la entrada y la salida de un elemento de la capa superior.
- Conocer el límite de la parte que sí necesita JavaScript.
El menú completo
<button class="disparador" popovertarget="menu-acciones">Acciones</button>
<div id="menu-acciones" class="menu" popover>
<button command="--duplicar" commandfor="lista">Duplicar</button>
<button command="--archivar" commandfor="lista">Archivar</button>
<hr>
<button command="--eliminar" commandfor="lista">Eliminar</button>
</div>
.menu {
/* la hoja de usuario ya le da position: fixed al mostrarse */
position-anchor: auto; /* usa el ancla implícita del invocador */
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
position-try-order: most-block-size;
margin-block: .375rem; /* separación del ancla en ambos sentidos */
min-inline-size: anchor-size(width);
max-block-size: 18rem;
overflow-y: auto;
inset: auto; /* anula el centrado de la hoja de usuario */
border: 1px solid color-mix(in oklch, CanvasText 20%, transparent);
border-radius: .5rem;
padding: .25rem;
background: Canvas;
box-shadow: 0 8px 24px oklch(0% 0 0 / .18);
}
.menu button {
display: block;
inline-size: 100%;
text-align: start;
padding: .5rem .75rem;
border: 0;
border-radius: .3rem;
background: none;
color: inherit;
}
.menu button:hover,
.menu button:focus-visible { background: color-mix(in oklch, CanvasText 8%, transparent); }
No hay JavaScript de posicionamiento. Ni de apertura. Ni de cierre. Ni de gestión del apilamiento.
Repasemos las decisiones que no son obvias.
position-anchor: auto aprovecha que popovertarget establece un ancla implícita entre el botón y el popover. No hace falta anchor-name ni identificadores, y el patrón se puede repetir en cada fila de una lista sin colisiones de nombres, porque cada botón es el ancla de su propio menú.
inset: auto es imprescindible. La hoja de estilos del navegador da a los elementos con popover un inset: 0 y margin: auto para centrarlos en la ventana. Si no anulas ese inset, compite con la colocación por anclas. position-area desactiva los márgenes automáticos por sí solo, pero el inset hay que quitarlo.
margin-block: .375rem en los dos sentidos, no solo arriba, porque el menú puede acabar por encima o por debajo según los fallbacks, y el hueco tiene que existir en ambos casos.
min-inline-size: anchor-size(width) hace que el menú sea al menos tan ancho como el botón, pero pueda crecer si una etiqueta es larga. Con inline-size en lugar de min-inline-size quedaría clavado al ancho del botón y truncaría el texto.
position-try-order: most-block-size combinado con max-block-size y overflow-y: auto da el comportamiento correcto cerca de los bordes: el menú se abre hacia donde hay más sitio y, si aun así no cabe, se recorta con scroll propio en vez de salirse.
El tooltip
Mismo mecanismo, distinta configuración. Un tooltip va centrado sobre su ancla y suele necesitar manual en lugar de auto, porque no debe cerrar al menú desde el que se muestra.
<button class="icono" popovertarget="ayuda-formato" aria-describedby="ayuda-formato">?</button>
<div id="ayuda-formato" class="tooltip" popover="manual" role="tooltip">
Acepta Markdown básico: negrita, cursiva y enlaces.
</div>
.tooltip {
position-anchor: auto;
position-area: block-start center;
position-try-fallbacks: block-end center, inline-end center, inline-start center;
inset: auto;
margin: .375rem;
max-inline-size: 18rem;
padding: .5rem .75rem;
border-radius: .375rem;
background: CanvasText;
color: Canvas;
font-size: .875rem;
}
El aria-describedby es la parte que no da la plataforma y sí hay que escribir: la relación de descripción entre el control y su ayuda. role="tooltip" completa la semántica.
Aquí está el único punto donde sigue haciendo falta JavaScript: mostrar y ocultar al pasar el ratón o al enfocar. popovertarget funciona con clic, no con hover. Cuatro líneas resuelven el caso:
document.querySelectorAll('[data-tooltip]').forEach((disparador) => {
const t = document.getElementById(disparador.getAttribute('aria-describedby'));
const abrir = () => t.showPopover();
const cerrar = () => t.hidePopover();
disparador.addEventListener('pointerenter', abrir);
disparador.addEventListener('pointerleave', cerrar);
disparador.addEventListener('focus', abrir);
disparador.addEventListener('blur', cerrar);
});
Existe una propuesta para cubrir esto de forma declarativa, el atributo interestfor, pero a agosto de 2026 solo está en Chrome 142 y no es estándar. Las cuatro líneas siguen siendo la respuesta.
flowchart TB
A[Necesitas un menu o un tooltip anclado] --> B[popover para la capa superior]
B --> C[popovertarget establece el ancla implicita]
C --> D[position-anchor auto en el CSS]
D --> E[position-area para colocar en la rejilla]
E --> F[position-try-fallbacks para cuando no cabe]
F --> G[inset auto para anular el centrado por defecto]
G --> H{Se abre con hover o con clic}
H -->|Clic| I[Listo, cero JavaScript]
H -->|Hover| J[Cuatro lineas con pointerenter y focus]
style A fill:#89b4fa,color:#11111b
style D fill:#cba6f7,color:#11111b
style G fill:#f9e2af,color:#11111b
style I fill:#a6e3a1,color:#11111b
style J fill:#a6e3a1,color:#11111bAnimar la entrada y la salida
Un elemento con popover pasa de display: none a visible. Históricamente eso impedía cualquier transición, porque display es una propiedad discreta y el elemento no existía para animar en el estado inicial. Dos añadidos lo resolvieron y están en los tres motores desde 2024.
transition-behavior: allow-discrete permite que las propiedades discretas —display, overlay— participen en una transición, cambiando al final o al principio del intervalo según convenga.
@starting-style define el estado desde el que se anima un elemento que acaba de aparecer.
.menu {
opacity: 1;
translate: 0 0;
transition:
opacity .15s,
translate .15s,
display .15s allow-discrete,
overlay .15s allow-discrete;
}
/* estado al que va cuando se cierra */
.menu:not(:popover-open) {
opacity: 0;
translate: 0 -.25rem;
}
/* estado desde el que llega cuando se abre */
@starting-style {
.menu:popover-open { opacity: 0; translate: 0 -.25rem; }
}
@media (prefers-reduced-motion: reduce) {
.menu { transition-duration: 1ms; }
}
La propiedad overlay de la lista merece una nota: controla cuándo el elemento sale de la capa superior, y sin ella el popover se quita de la capa nada más cerrarse, con lo que la animación de salida se ve por debajo del resto del contenido. Pero overlay es solo Chromium: Chrome desde la 117, y ni Firefox ni Safari la han implementado. Incluirla en la lista de transición no rompe nada donde no existe —la declaración se ignora—, así que se escribe siempre y mejora donde se puede.
El ::backdrop se anima igual y con las mismas reglas, declarando sus propias transiciones y su propio @starting-style.
Es tentador medir el valor de esto en kilobytes: te ahorras la biblioteca de posicionamiento y el gestor de superposiciones. Lo que de verdad cambia es otra cosa. Las superposiciones escritas a mano tienen un modo de fallo característico que aparece siempre en el mismo sitio: en la frontera entre componentes de equipos distintos. El menú funciona hasta que alguien envuelve la lista en un contenedor con transform para una animación de entrada, y entonces el position: fixed deja de anclarse al viewport; funciona hasta que alguien añade will-change a las filas por rendimiento, y entonces el z-index deja de salir; funciona hasta que alguien mete un overflow: hidden para redondear una esquina, y entonces el desplegable se corta. Ninguno de esos cambios toca el código del menú, ninguno aparece en su revisión, y los tres lo rompen. Esa es la definición operativa de acoplamiento no local: un componente cuya corrección depende de propiedades CSS de ancestros que no controla. La capa superior corta esa dependencia por completo, porque el elemento deja de pintarse en el árbol donde vive; y el anclaje corta la otra mitad, porque la geometría deja de depender de la cadena de bloques contenedores. Lo que compras no es rendimiento ni brevedad: es que el menú deja de poder romperse por un cambio ajeno. En una base de código con varios equipos, esa propiedad vale más que cualquier optimización.
- Construye el menú completo y comprueba que funciona con el botón dentro de un ancestro con
transform. - Quita el
inset: autoy observa dónde acaba el menú. - Coloca el botón en las cuatro esquinas de la ventana y verifica los cuatro fallbacks.
- Sustituye
min-inline-sizeporinline-sizey comprueba qué pasa con una etiqueta larga. - Añade la animación de entrada y salida y comprueba con el ralentizador de animaciones de DevTools que la salida se ve completa.