CSS moderno: animar display none con @starting-style y @scope
Te ha pasado. Pones transition: opacity 0.3s en el modal y aparece de golpe. Lo cierras y desaparece de golpe. El DevTools marca la transición como aplicada, y no hace nada.
Acabas con un setTimeout de 300 ms que oculta el modal cuando "ya debería" haber terminado. Y en el mismo proyecto, otro clásico: una card que no se deja sobrescribir porque alguien escribió .catalog .card .price, y la salida fue un !important.
Los dos problemas tienen hoy solución nativa en CSS moderno, sin librerías.
En corto: @starting-style le da al navegador el estado inicial de un elemento que sale de display: none, y transition-behavior: allow-discrete retrasa el cambio a display: none hasta que acaba la salida. @scope limita estilos a un trozo del DOM sin añadir especificidad y, si dos reglas empatan, gana la raíz más cercana. Todo funciona en los navegadores principales en 2026, salvo animar display en Firefox y la propiedad overlay fuera de Chromium.
Por qué no puedes animar display none con una transición normal
display es una propiedad discreta: no tiene valores intermedios, así que el navegador la cambia de golpe y arrastra contigo el resto de la transición.
Hay dos fallos distintos. En la salida, el elemento pasa a display: none en el primer fotograma y la opacidad no llega a verse. En la entrada, el elemento no existía en el árbol de renderizado, así que el navegador no tiene un "antes" desde el que transicionar: pinta directamente el estado final.
No es un problema nuevo: la pregunta Transitions on the CSS display property lleva abierta en Stack Overflow desde julio de 2010, con más de 2,6 millones de visitas y respuestas que durante años fueron variantes del mismo hack con visibility, max-height o setTimeout.
La salida se arregla con allow-discrete. La entrada, con @starting-style. Necesitas las dos.
¿Qué es @starting-style?
@starting-style es una regla que define los valores desde los que transiciona un elemento en su primer pintado, por ejemplo cuando pasa de display: none a visible.
Va acompañada de transition-behavior: allow-discrete, que hace que una propiedad discreta participe en la transición. Según MDN, cuando el destino es display: none el cambio se aplica al 100 % de la duración, y cuando el origen es none se aplica al 0 %. Es justo lo que quieres: visible durante toda la animación en ambos sentidos.
Este es un <dialog> de confirmación que entra y sale deslizándose, sin una línea de JavaScript para la animación:
dialog.confirm {
opacity: 0;
translate: 0 1rem;
transition:
opacity 0.25s ease-out,
translate 0.25s ease-out,
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog.confirm[open] {
opacity: 1;
translate: 0 0;
}
@starting-style {
dialog.confirm[open] {
opacity: 0;
translate: 0 1rem;
}
}
<button type="button"
Eliminar proyecto
</button>
<dialog class="confirm">
<p>¿Seguro? Esta acción no se puede deshacer.</p>
<form method="dialog">
<button value="cancel">Cancelar</button>
<button value="ok">Eliminar</button>
</form>
</dialog>
Tres detalles.
El bloque @starting-style va después de la regla [open]: tienen la misma especificidad y, si lo pones antes, la regla normal lo pisa.
La propiedad overlay solo importa en elementos del top layer (<dialog> modal, popovers): sin ella, el navegador saca el elemento de la capa superior antes de que termine la salida. MDN avisa de que en un fundido simple apenas se nota.
Y overlay solo existe en Chromium: en el resto se ignora sin romper nada.
La animación es la parte fácil del modal: el foco al abrir y cerrar importa más, y lo vimos en cómo gestionar el foco con Angular Aria.
Si prefieres @keyframes, también puedes meter display dentro. Para un toast que se va:
.toast.is-leaving {
animation: toast-out 0.3s ease-in forwards;
}
@keyframes toast-out {
to {
opacity: 0;
translate: 0 0.5rem;
display: none;
}
}
Antes, ese display se ignoraba. Hoy, en Chrome y Safari, el toast se desvanece y al final deja de ocupar espacio. Con @keyframes no necesitas @starting-style: el primer fotograma ya es el punto de partida.
Si lo que animas es un cambio de vista entero y no un elemento suelto, la herramienta es otra: lo contamos en el post sobre View Transitions en React 19.3.
Soporte en navegadores: lo que el CSS moderno ya cubre en 2026
Todo lo de este post está disponible en Chrome, Edge, Safari y Firefox salvo animar display y la propiedad overlay. Los datos salen de MDN browser-compat-data y del estado Baseline de webstatus.dev, consultados en octubre de 2026:
| Característica | Chrome / Edge | Firefox | Safari | Estado Baseline |
|---|---|---|---|---|
@starting-style |
117 | 129 | 17.5 | Newly available (agosto 2024) |
transition-behavior: allow-discrete |
117 | 129 | 17.4 | Newly available (agosto 2024) |
Animar display (keyframes y transición) |
117 | No (bug 1834877) | 18 | Limited availability |
overlay |
117 | No | No | Limited availability |
@scope |
118 | 146 | 17.4 (fallo en 26.0–26.3 con <input>/<textarea>) |
Newly available (24 marzo 2026) |
Chrome soporta @scope desde la 118; webstatus.dev marca la 143 porque cuenta también el tratamiento de & dentro de @scope como :where(:scope). En Chrome, display dentro de @keyframes funciona desde la 116; la 117 es la de las transiciones.
La lectura práctica: en Firefox la entrada con @starting-style funciona, porque el elemento pasa de none a visible al instante igualmente y la opacidad sí transiciona. La salida no: el elemento desaparece de golpe. Es mejora progresiva pura, nadie se queda sin modal.
¿Qué es @scope en CSS?
@scope es una regla que aplica estilos solo dentro de un subárbol del DOM, delimitado por una raíz y, opcionalmente, por un límite inferior, sin sumar especificidad a los selectores de dentro.
Esa última parte es la clave. Según MDN, los selectores dentro de @scope se comportan como si llevaran :where(:scope) delante:
@scope (.product-card) {
img { aspect-ratio: 4 / 3; object-fit: cover; } /* 0-0-1 */
:scope { border: 1px solid #d0d7de; } /* 0-1-0: estiliza la propia card */
:scope img { border-radius: 8px; } /* 0-1-1 */
}
.product-card img pesa 0-1-1; dentro del scope, img pesa 0-0-1, y cualquier utilidad posterior lo sobrescribe sin pelear. Para tocar la raíz usas :scope, que sí suma como una clase. También admite varias raíces: @scope (.product-card, .cart-line) { … }.
El donut: parar antes de llegar al contenido ajeno
La card tiene un bloque de reseñas con avatares. No quieres que el aspect-ratio de las fotos de producto los deforme:
@scope (.product-card) to (.card-reviews) {
img { aspect-ratio: 4 / 3; object-fit: cover; }
}
La raíz es inclusiva (:scope la alcanza) y el límite es exclusivo: .card-reviews y todo lo que contiene queda fuera. Si quieres incluir el propio .card-reviews pero no sus hijos, usa to (.card-reviews > *). Y si el límite solo debe contar cuando es hijo directo de la card, to (:scope > .card-reviews).
Proximidad: gana la raíz más cercana
Una tienda con tema oscuro, un bloque promocional claro y, dentro, una card destacada otra vez oscura:
<main class="theme-dark">
<section class="promo theme-light">
<p class="price">29 €</p>
<article class="product-card theme-dark">
<p class="price">49 €</p>
</article>
</section>
</main>
Con .theme-light .price y .theme-dark .price (los dos 0-2-0), el segundo precio coincide con ambas reglas y gana la que esté escrita más abajo en la hoja. Si la clara va última, el precio de la card oscura sale con el color del tema claro. Con @scope:
@scope (.theme-light) {
.price { color: #0b5394; }
}
@scope (.theme-dark) {
.price { color: #7fd1ff; }
}
Cada precio toma el color de la raíz con menos saltos hacia arriba en el DOM, da igual el orden. Es la misma idea de "gana el tema más cercano" que vimos con light-dark() en imágenes, pero para cualquier propiedad.
La proximidad va después de la especificidad y antes del orden de aparición. En el issue #6790 de csswg-drafts el grupo de trabajo debatió entre proximidad "fuerte" (por encima de la especificidad) y "débil", y en marzo de 2023 resolvió que "cascade proximity is weaker than specificity". Un :scope .price le gana a un .price aunque su raíz esté más lejos.
@scope frente a BEM, CSS Modules y Angular: qué aporta el CSS moderno
@scope no sustituye a todas las técnicas de aislamiento: ocupa el hueco entre el selector descendiente y el Shadow DOM.
| Técnica | Especificidad | Límite inferior | Riesgo o limitación |
|---|---|---|---|
Selector descendiente (.card img) |
Suma la del ancestro | No | Se filtra a todo lo anidado y obliga a escalar especificidad |
BEM (.card__img) |
Una clase plana | No hace falta | Depende de disciplina humana; nombres largos en el HTML |
| CSS Modules | Una clase con hash | No | Requiere build; no protege de estilos globales |
| Encapsulación emulada de Angular | Suma un atributo [_ngcontent-…] por compuesto |
No | Para traspasar a hijos acabas en ::ng-deep, que Angular desaconseja y mantiene solo por compatibilidad |
@scope |
Cero (salvo :scope) |
Sí, con to (…) |
Los estilos globales siguen entrando; soporte completo solo desde 2026 |
| Shadow DOM | Aislamiento real | Sí (frontera del shadow root) | Aísla demasiado: tematizar exige custom properties o ::part |
Ojo en Angular: @scope no sustituye a ::ng-deep. Con la encapsulación emulada, el compilador también reescribe los selectores de dentro de @scope con el atributo [_ngcontent-…] del componente, así que no llegan al contenido proyectado. Donde sí encaja es en los estilos globales o en componentes con ViewEncapsulation.None: ahí acota hasta dónde baja una regla, con un límite inferior que la encapsulación no te da. Si montas una arquitectura de componentes desde cero, en el curso de Angular Moderno verás dónde termina la encapsulación del framework y empieza tu CSS.
Cuándo NO usar @scope (y límites de @starting-style)
No uses estas herramientas como si fueran aislamiento total o animación garantizada en todos los navegadores.
@scope no es Shadow DOM. Limita hacia dónde salen tus estilos, no lo que entra: un p { color: red } global sigue pintando dentro. Para un widget embebido en webs ajenas, sigues necesitando Shadow DOM.
El soporte completo es reciente. Firefox lo sacó en la 146 y Safari corrigió en la 26.4 un fallo que impedía aplicar reglas a <input> y <textarea>. En un Firefox anterior a la 146, la regla dentro de @scope no se aplica y punto. Para estilos críticos de layout, ten una versión fuera de @scope.
La proximidad cuesta depurar. El DevTools te enseña la regla vencedora, pero no siempre deja claro por qué ganó. Con temas anidados en tres niveles, documenta la intención en el CSS.
@starting-style no sirve para la salida. Solo define el estado de partida al aparecer. La salida depende de allow-discrete y, en Firefox, no existe para display. Si el elemento se elimina del DOM en vez de ocultarse, espera a transitionend antes de quitarlo.
display en @keyframes no quita el elemento del layout en Firefox. Con forwards, el toast termina transparente pero ocupando su hueco. Escucha animationend y quítalo con JavaScript si el espacio importa.
Cómo empezar a usar CSS moderno en tu proyecto
Empieza por las animaciones de salida: son el cambio de CSS moderno con menos riesgo, porque si el navegador no lo soporta el elemento solo se cierra sin transición.
Busca en tu código los setTimeout que esperan a una animación antes de poner display: none. Cada uno es candidato a allow-discrete más @starting-style, y en Firefox la salida degrada a un cierre instantáneo que nadie reportará como bug.
Hacemos estas revisiones sobre código real en Dominicode Labs, y en el canal de YouTube de Dominicode cubrimos las novedades de la plataforma web.
Preguntas frecuentes
¿Qué diferencia hay entre @starting-style y transition-behavior: allow-discrete?
@starting-style define desde qué valores empieza la transición cuando un elemento aparece. allow-discrete permite que propiedades discretas como display formen parte de la transición. Para animar entrada y salida desde display: none necesitas las dos.
¿Necesito la propiedad overlay para animar un dialog?
Solo para elementos en el top layer, como un <dialog> abierto con showModal() o un popover. Retrasa su salida de la capa superior hasta que termina la transición. Hoy solo la soportan Chrome y Edge; en Safari y Firefox se ignora sin errores.
¿@scope añade especificidad a los selectores?
No. La raíz del scope no suma nada: img dentro de @scope (.product-card) pesa 0-0-1. La excepción es :scope, que cuenta como una pseudoclase y suma 0-1-0.
¿Puedo usar @scope en producción en 2026?
Sí, si tu público usa navegadores actualizados: es Baseline desde el 24 de marzo de 2026, con Firefox 146 y Safari 26.4. Si necesitas cubrir versiones anteriores, mantén fuera de @scope las reglas de las que depende el layout.
¿Qué gana si dos reglas de @scope tienen la misma especificidad?
Gana la que tenga la raíz más cercana al elemento en el DOM, sin importar el orden en la hoja. Si la especificidad es distinta, manda la especificidad: la proximidad solo desempata.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
