wandres.dev
ANIMAR LA ENTRADA · @starting-style y transition-behavior

El patrón completo de entrada y salida sin JavaScript

Las cuatro piezas montadas: popover, dialog con su backdrop y un panel genérico, con las duraciones, el orden y la variante de movimiento reducido.

⏱ 20 min

Con @starting-style para el punto de partida, allow-discrete para que display participe y overlay para la capa superior, el problema de animar la aparición y la desaparición de un elemento queda resuelto en CSS. Esta lección monta las piezas en los tres casos que cubren casi toda la práctica —un popover, un dialog modal con su fondo, y un panel genérico gobernado por un atributo— y señala los detalles de colocación que hacen que cada uno funcione o no.

🎯 Al terminar esta lección sabrás
  • Montar el patrón completo para un popover y verificar las dos direcciones.
  • Animar el ::backdrop de un dialog y explicar qué exige hacerlo.
  • Aplicar el patrón a un elemento cualquiera gobernado por un atributo.
  • Escribir la variante de movimiento reducido sin romper la lógica de display.

Regla de colocación, primero

Las tres implementaciones comparten una estructura y conviene enunciarla antes, porque los errores de este patrón son casi siempre de colocación y no de sintaxis.

La declaración transition va en la regla base, la que se aplica en los dos estados. Si la pones solo en la regla del estado abierto, la salida no se anima, por lo que ya vimos en el modelo de transición.

Los valores del estado abierto van en la regla del estado abierto. Es decir en :popover-open, en [open] o en tu atributo.

El @starting-style va dentro de la regla del estado abierto, o después de ella. Nunca antes, porque tienen la misma especificidad y ganaría la última.

display y overlay van en la lista de transición con allow-discrete, con la misma duración que el resto para que todo termine a la vez.

Los tres casos que cubren la práctica

Caso uno: un popover

El atributo popover da la promoción a la capa superior, el cierre con la tecla de escape, el cierre al pulsar fuera y la gestión del foco, todo sin JavaScript. Lo único que falta es la animación.

<button popovertarget="menu">Opciones</button>

<div id="menu" popover>
  <button>Duplicar</button>
  <button>Renombrar</button>
  <button>Eliminar</button>
</div>
[popover] {
  /* Estado cerrado y transicion: en la regla base, para las dos direcciones. */
  opacity: 0;
  translate: 0 -6px;
  scale: 0.98;

  transition:
    opacity 200ms ease-out,
    translate 240ms cubic-bezier(0.2, 0, 0, 1),
    scale 240ms cubic-bezier(0.2, 0, 0, 1),
    display 240ms allow-discrete,
    overlay 240ms allow-discrete;

  /* Presentacion */
  margin: 0;
  padding: 0.5rem;
  border: 1px solid oklch(88% 0.01 250);
  border-radius: 0.5rem;
  background: oklch(99% 0.005 250);
  color: #11111b;
}

/* Estado abierto */
[popover]:popover-open {
  opacity: 1;
  translate: 0 0;
  scale: 1;
}

/* Punto de partida, despues de la regla del estado abierto */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    translate: 0 -6px;
    scale: 0.98;
  }
}

Fíjate en que los valores del estado cerrado y los del @starting-style son idénticos. No es casualidad: en un elemento que aparece y desaparece, el estado de partida y el de llegada son el mismo, porque la animación de entrada es la inversa de la de salida. Si te encuentras escribiendo valores distintos, es que quieres una entrada y una salida asimétricas, lo cual es legítimo pero conviene que sea deliberado.

Un detalle que ahorra duplicación: como los tres valores se repiten, se pueden extraer a variables.

[popover] {
  --y: -6px;
  --s: 0.98;
  opacity: 0;
  translate: 0 var(--y);
  scale: var(--s);
  /* ... transicion ... */
}

[popover]:popover-open { opacity: 1; translate: 0 0; scale: 1; }

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    translate: 0 var(--y);
    scale: var(--s);
  }
}

Caso dos: un dialog modal con su fondo

Un dialog abierto con showModal() añade dos cosas: el atributo open y un pseudo-elemento ::backdrop que cubre el resto de la página. Los dos se animan por separado y los dos necesitan el patrón.

dialog {
  opacity: 0;
  translate: 0 16px;

  transition:
    opacity 200ms ease-out,
    translate 280ms cubic-bezier(0.2, 0, 0, 1),
    display 280ms allow-discrete,
    overlay 280ms allow-discrete;

  border: 0;
  border-radius: 0.75rem;
  padding: 1.5rem;
  background: oklch(99% 0.005 250);
  color: #11111b;
}

dialog[open] {
  opacity: 1;
  translate: 0 0;
}

@starting-style {
  dialog[open] {
    opacity: 0;
    translate: 0 16px;
  }
}

/* El fondo es un pseudo-elemento y lleva su propio patron. */
dialog::backdrop {
  background: rgb(0 0 0 / 0);
  transition:
    background-color 200ms linear,
    display 200ms allow-discrete,
    overlay 200ms allow-discrete;
}

dialog[open]::backdrop {
  background: rgb(0 0 0 / 0.45);
}

@starting-style {
  dialog[open]::backdrop {
    background: rgb(0 0 0 / 0);
  }
}

Aquí hay una dependencia real que conviene conocer: el ::backdrop solo existe mientras el elemento está en la capa superior. Al cerrar el diálogo, si el elemento sale de la capa superior de inmediato, el pseudo-elemento deja de existir y su animación de salida no llega a verse. Retrasar esa salida es exactamente lo que hace overlay, y por eso la animación de salida del fondo depende de una propiedad que, como vimos en la lección anterior, solo implementa Chromium en 2026.

La consecuencia práctica es que la entrada del fondo se anima en todos los motores y la salida solo donde hay overlay. En los demás, el fondo desaparece de golpe mientras el diálogo se va animado. Es un fallo cosmético, no funcional, y la forma de que apenas se note es que la salida del fondo sea corta —los 200 milisegundos del ejemplo frente a los 280 del diálogo— para que la discrepancia dure poco.

Y el JavaScript necesario sigue siendo solo el de abrir y cerrar, sin nada de animación:

document.querySelector('#abrir').addEventListener('click', () => {
  document.querySelector('dialog').showModal();
});

document.querySelector('#cerrar').addEventListener('click', () => {
  document.querySelector('dialog').close();
});
El patrón funciona con display: none, y eso cambia lo que puedes permitirte tener en la página

El valor de este patrón se suele medir en líneas de JavaScript ahorradas, y esa es la parte menos interesante. Lo que de verdad cambia es qué puedes dejar montado en el documento. Antes, un componente que se animaba al aparecer tenía que estar renderizado permanentemente, porque display: none mataba la animación: es decir, ocupaba layout, se pintaba, se rasterizaba y estaba en el árbol de accesibilidad aunque nadie lo viera. Con doscientas filas que tienen un menú contextual cada una, eso son doscientos subárboles vivos que el motor recalcula en cada cambio de tamaño de la ventana y que un lector de pantalla recorre. La alternativa era montarlos y desmontarlos desde JavaScript, con su coste de creación de nodos, su parpadeo en el primer fotograma y su gestión de foco a mano. Con allow-discrete sobre display, la tercera opción existe: el componente está en el DOM pero con display: none, sin coste de layout, sin coste de pintado y fuera del árbol de accesibilidad, y aun así entra y sale animado. La diferencia en una página con muchos componentes ocultos no es de matiz: en una tabla con doscientas filas, pasar de doscientos menús renderizados a doscientos menús en display: none puede quitar la mitad del tiempo de layout de cada recálculo. Y trae de propina la corrección del bug de accesibilidad más habitual de este tipo de componentes, el del foco que se cuela en un panel cerrado, sin necesidad de gestionar inert ni tabindex a mano. La forma correcta de valorar este patrón no es “ahorro un addEventListener”: es que el estado oculto vuelve a ser gratis.

Caso tres: un panel gobernado por un atributo

Cuando el elemento no es un popover ni un dialog, el patrón es idéntico salvo por el selector, y no hace falta overlay porque no hay capa superior implicada.

.panel-lateral {
  display: none;
  translate: 100% 0;
  opacity: 0;

  transition:
    translate 300ms cubic-bezier(0.2, 0, 0, 1),
    opacity 200ms linear,
    display 300ms allow-discrete;

  position: fixed;
  inset-block: 0;
  inset-inline-end: 0;
  inline-size: min(22rem, 90vw);
}

.panel-lateral[data-abierto] {
  display: block;
  translate: 0 0;
  opacity: 1;
}

@starting-style {
  .panel-lateral[data-abierto] {
    translate: 100% 0;
    opacity: 0;
  }
}
// Todo el JavaScript necesario.
boton.addEventListener('click', () => {
  panel.toggleAttribute('data-abierto');
});

Fíjate en que el @starting-style no repite display: block: no hace falta, porque el estilo inicial se calcula sobre el estilo del estado abierto y solo se sobreescribe lo que declares. Repetirlo no rompe nada pero es ruido.

La variante de movimiento reducido

Aquí hay una trampa concreta: si en la variante reducida escribes transition: none, también desactivas la transición de display, y con ella la regla especial que mantenía el elemento renderizado durante la salida. El resultado es que el elemento desaparece de golpe, que era lo que querías, pero también que cualquier desvanecido que hubieras conservado se corta.

La forma correcta es conservar la entrada de display con su allow-discrete y quitar solo el movimiento:

@media (prefers-reduced-motion: reduce) {
  .panel-lateral {
    transition:
      opacity 120ms linear,
      display 120ms allow-discrete;
  }

  .panel-lateral[data-abierto] {
    translate: 0 0;
  }

  @starting-style {
    .panel-lateral[data-abierto] {
      translate: 0 0;
      opacity: 0;
    }
  }
}

El translate se iguala en los tres sitios para que no haya desplazamiento, y el desvanecido corto se conserva. display sigue en la lista, así que el elemento sigue renderizándose durante los 120 milisegundos del desvanecido.

Comprobaciones

Cuatro comprobaciones para dar el patrón por bueno, y las cuatro fallan de formas distintas si algo está mal colocado.

La entrada se anima. Si no, el @starting-style está antes de la regla del estado abierto o en la regla equivocada.

La salida se anima. Si no, la declaración transition está solo en el estado abierto, o falta allow-discrete en display.

El elemento no ocupa espacio cuando está cerrado. Comprueba con las herramientas que display computa a none. Si no, se te ha colado un visibility en lugar de un display.

El foco no entra cuando está cerrado. Tabula hasta pasar por encima. Si el foco entra, el elemento se sigue renderizando.

⚔️ Monta los tres casos
  1. Implementa el popover completo y verifica las cuatro comprobaciones.
  2. Rompe el patrón moviendo la declaración transition al estado abierto y anota qué comprobación falla.
  3. Implementa el dialog con su fondo y compara la salida del fondo en Chromium y en otro motor.
  4. Escribe la variante de movimiento reducido con transition: none y comprueba qué se rompe. Corrígela con la forma de esta lección.