Dominando @starting-style e Transições de Display

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que durou 15 anos
Animar um elemento de display: none para display: block sempre foi impossível em CSS puro. A propriedade display não é interpolável: ela muda de valor discretamente, num único frame. Quando o navegador encontra display: none, o elemento sai do layout e perde todo contexto de transição. Quando volta para display: block, ele aparece imediatamente, sem estado intermediário.
A solução historicamente envolvia JavaScript: adicionar uma classe, esperar o próximo frame com requestAnimationFrame (ou pior, setTimeout), e só então aplicar os estilos finais. Para a saída, era preciso escutar transitionend, e só depois setar display: none. Bibliotecas como Framer Motion, React Transition Group e GSAP existem em parte por causa dessa limitação.
Duas features CSS mudam isso: @starting-style e transition-behavior: allow-discrete. Juntas, elas permitem definir o estado inicial de um elemento que acabou de aparecer e transicionar propriedades discretas como display e overlay. O suporte já cobre Chrome 117+, Edge 117+ e Safari 17.5+. Firefox 129+ também implementa.
Como @starting-style funciona por baixo
Quando o navegador renderiza um elemento pela primeira vez (inserção no DOM ou mudança de display: none para qualquer valor visível), ele precisa de dois estados para interpolar: o estado "antes" e o estado "depois". Sem @starting-style, o "antes" não existe: o elemento simplesmente aparece com os estilos computados finais.
@starting-style define exatamente esse estado "antes". O motor de renderização lê os valores declarados dentro do bloco @starting-style, usa-os como ponto de partida, e transiciona até os valores computados normais do elemento.
/* O seletor dentro de @starting-style precisa ter
especificidade suficiente para casar com o elemento alvo */
.notification {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms ease-out, transform 300ms ease-out;
@starting-style {
/* Estado "antes": de onde a transição parte quando o elemento aparece */
opacity: 0;
transform: translateY(-20px);
}
}Esse bloco é avaliado apenas uma vez, no momento da primeira renderização do elemento. Depois que a transição completa, @starting-style é ignorado. Ele não afeta hover, focus ou qualquer outra mudança de estado posterior.
Transicionando display com allow-discrete
@starting-style sozinho resolve a animação de entrada para propriedades interpoláveis (opacity, transform). Para display, você precisa de mais uma peça: transition-behavior: allow-discrete.
Sem essa declaração, o navegador ignora display na lista de transições. Com ela, display participa da transição, mas de forma discreta: o valor muda no início da transição (entrada) ou no final (saída). Isso significa que, na entrada, display: block é aplicado imediatamente e as outras propriedades animam normalmente. Na saída, display: none só é aplicado quando as outras transições terminam.
.modal-overlay {
display: none;
opacity: 0;
transition:
display 400ms allow-discrete,
opacity 400ms ease-out;
@starting-style {
opacity: 0;
}
}
/* Quando o atributo open é adicionado (via JS ou <dialog>),
o elemento transiciona de opacity: 0 para opacity: 1.
display muda de none para block no primeiro frame. */
.modal-overlay.is-open {
display: block;
opacity: 1;
}A sintaxe 400ms allow-discrete é um shorthand: transition-behavior: allow-discrete aplicado especificamente à propriedade display dentro da declaração transition. Você também pode declarar separadamente:
.modal-overlay {
transition: display 400ms, opacity 400ms ease-out;
transition-behavior: allow-discrete;
}Animação de entrada e saída completa: o padrão que funciona
O cenário mais comum é um modal ou toast que precisa animar tanto na entrada quanto na saída. O padrão completo combina @starting-style (para a entrada), allow-discrete (para transicionar display) e os estados aberto/fechado:
.toast {
/* Estado fechado: invisível e fora do layout */
display: none;
opacity: 0;
transform: translateX(100%);
/* Todas as propriedades que participam da transição */
transition:
display 350ms allow-discrete,
opacity 350ms ease-out,
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
/* Estado de partida quando o elemento aparece pela primeira vez */
@starting-style {
opacity: 0;
transform: translateX(100%);
}
}
.toast.is-visible {
/* Estado aberto: visível e posicionado */
display: flex;
opacity: 1;
transform: translateX(0);
}O fluxo é:
- JS adiciona a classe
is-visible. - O navegador detecta que
displaymudou denoneparaflex. @starting-styleé consultado: opacity começa em 0, transform começa em translateX(100%).- A transição interpola até opacity: 1 e translateX(0).
- Quando JS remove
is-visible, os valores voltam para o estado fechado. display: nonesó é aplicado quando opacity e transform terminam de transicionar.
O JavaScript necessário se reduz a uma linha:
// Entrada
document.querySelector('.toast').classList.add('is-visible');
// Saída: basta remover a classe. O CSS cuida do timing de display: none.
document.querySelector('.toast').classList.remove('is-visible');Sem transitionend listener. Sem requestAnimationFrame. Sem timeout.
<dialog> e Popover API: onde @starting-style brilha de verdade
O elemento <dialog> e a Popover API (popover attribute) alternam entre camadas (top layer) e display: none nativamente. Antes de @starting-style, não havia como animar essas transições sem JS.
<dialog id="confirm-dialog">
<h2>Confirmar ação</h2>
<p>Essa operação não pode ser desfeita.</p>
<form method="dialog">
<button value="cancel">Cancelar</button>
<button value="confirm">Confirmar</button>
</form>
</dialog>
<button onclick="document.getElementById('confirm-dialog').showModal()">
Abrir dialog
</button>dialog[open] {
opacity: 1;
transform: scale(1);
}
dialog {
opacity: 0;
transform: scale(0.95);
transition:
display 250ms allow-discrete,
overlay 250ms allow-discrete,
opacity 250ms ease-out,
transform 250ms ease-out;
/* overlay precisa de allow-discrete para que o backdrop
permaneça visível durante a animação de saída */
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}
/* Backdrop também pode ser animado */
dialog::backdrop {
background: rgb(0 0 0 / 0);
transition:
display 250ms allow-discrete,
overlay 250ms allow-discrete,
background 250ms ease-out;
}
dialog[open]::backdrop {
background: rgb(0 0 0 / 0.4);
@starting-style {
background: rgb(0 0 0 / 0);
}
}A propriedade overlay controla se o elemento permanece na top layer durante a animação de saída. Sem transicionar overlay, o dialog desaparece da top layer imediatamente ao fechar, e a animação de saída fica invisível.
Se você trabalha com componentes de UI como os do Radix UI integrado com Tailwind, essa abordagem CSS-only pode substituir boa parte da lógica de animação que hoje vive em JavaScript.
O que NÃO fazer
Erro 1: esquecer @starting-style e esperar que a entrada anime
/* ERRADO: sem @starting-style, o elemento aparece instantaneamente */
.dropdown {
display: none;
opacity: 0;
transition: display 200ms allow-discrete, opacity 200ms ease-out;
}
.dropdown.open {
display: block;
opacity: 1;
/* O navegador não tem estado "antes" para interpolar.
opacity já é 0 no estado fechado, mas display: none
impede que qualquer transição ocorra na entrada. */
}/* CORRETO: @starting-style fornece o ponto de partida */
.dropdown {
display: none;
opacity: 0;
transition: display 200ms allow-discrete, opacity 200ms ease-out;
@starting-style {
opacity: 0;
}
}
.dropdown.open {
display: block;
opacity: 1;
}Erro 2: não transicionar overlay em elementos da top layer
/* ERRADO: a animação de saída do dialog fica invisível */
dialog {
opacity: 0;
transition:
display 200ms allow-discrete,
opacity 200ms ease-out;
/* Falta overlay: o dialog sai da top layer no primeiro frame */
}/* CORRETO: overlay mantém o elemento na top layer durante a saída */
dialog {
opacity: 0;
transition:
display 200ms allow-discrete,
overlay 200ms allow-discrete,
opacity 200ms ease-out;
}Erro 3: usar @starting-style para estados que não são inserção
@starting-style só dispara na primeira renderização. Ele não substitui :hover, :focus ou qualquer pseudo-classe de interação. Se você quer animar hover, use transições normais:
/* ERRADO: @starting-style não dispara no hover */
.card {
transform: scale(1);
transition: transform 200ms ease-out;
@starting-style {
transform: scale(0.9);
}
}
.card:hover {
transform: scale(1.02);
}Nesse caso, @starting-style anima a entrada do card na página, mas não tem efeito no hover. A transição de hover funciona normalmente porque ambos os estados (normal e :hover) já existem no layout.
Comparação: antes e depois de @starting-style
| Aspecto | Abordagem JS clássica | CSS com @starting-style |
|---|---|---|
| Animação de entrada | requestAnimationFrame + classe | @starting-style + transition |
| Animação de saída | listener transitionend + display: none | allow-discrete resolve automaticamente |
| Linhas de JS | 15-30 (sem lib) | 1-2 (toggle de classe ou atributo) |
| Dependência de lib | Framer Motion, GSAP, React Transition Group | Nenhuma |
Funciona com <dialog> | Precisa interceptar close event | Nativo |
| Funciona com Popover API | Precisa de polyfill de animação | Nativo |
| Suporte a browsers | Todos | Chrome 117+, Safari 17.5+, Firefox 129+ |
| Fallback quando sem suporte | N/A | Elemento aparece/desaparece sem animação (funcional, só não anima) |
O fallback é gracioso: em browsers sem suporte, @starting-style é ignorado e allow-discrete também. O elemento simplesmente aparece e desaparece sem transição. A funcionalidade não quebra.
Integração com frameworks: React e o atributo popover
Em componentes React, o padrão se integra bem com refs e a Popover API nativa. O CSS faz o trabalho pesado, e o componente só gerencia estado:
import { useRef } from 'react';
function NotificationPopover({ message }: { message: string }) {
const popoverRef = useRef<HTMLDivElement>(null);
function toggle() {
// togglePopover() é nativo da Popover API
popoverRef.current?.togglePopover();
}
return (
<>
<button onClick={toggle}>Notificações</button>
{/* O atributo popover faz o browser gerenciar display automaticamente */}
<div ref={popoverRef} popover="auto" className="notification-popover">
<p>{message}</p>
</div>
</>
);
}
export default NotificationPopover;.notification-popover {
/* Estado fechado (popover escondido) */
opacity: 0;
transform: translateY(-8px);
transition:
display 200ms allow-discrete,
overlay 200ms allow-discrete,
opacity 200ms ease-out,
transform 200ms ease-out;
/* Posicionamento via anchor positioning ou CSS tradicional */
inset: unset;
top: 48px;
right: 16px;
@starting-style {
opacity: 0;
transform: translateY(-8px);
}
}
/* :popover-open é a pseudo-classe nativa para popovers visíveis */
.notification-popover:popover-open {
opacity: 1;
transform: translateY(0);
}Zero JavaScript de animação. O componente React não precisa de useState para controlar visibilidade, não precisa de useEffect para sincronizar animações, não precisa de Framer Motion. Se você já trabalha com CSS nativo para substituir lógica JS, esse é mais um caso onde a plataforma absorveu o que antes era responsabilidade de bibliotecas.
Para quem constrói componentes responsivos sem JavaScript, @starting-style complementa container queries: um cuida do layout adaptativo, o outro cuida das transições de visibilidade.
Checklist de implementação
Antes de adotar @starting-style em produção, verifique:
- O estado fechado do elemento usa
display: none? Se sim, você precisa deallow-discretena transição dedisplay. - O elemento participa da top layer (
<dialog>,popover)? Adicioneoverlayà lista de transições. - O
@starting-styledeclara os mesmos valores do estado fechado? Se os valores divergirem, a animação de entrada terá um "salto" visível. - O fallback é aceitável? Em browsers sem suporte, o elemento aparece/desaparece sem animação. Se isso é inaceitável, mantenha o JS como fallback.
- A duração da transição de
displayé igual ou maior que a das outras propriedades? Sedisplaytransicionar mais rápido, o elemento desaparece antes de opacity/transform completarem.
Animação de entrada com transform: slide + fade
/* dropdown.css */
.dropdown-menu {
display: none;
opacity: 0;
transform: translateY(-8px);
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px;
transition:
opacity 200ms ease-out,
transform 200ms ease-out,
display 200ms ease-out allow-discrete;
/* Ponto de partida quando o elemento se torna visível */
@starting-style {
opacity: 0;
transform: translateY(-8px);
}
}
.dropdown-trigger:focus-within + .dropdown-menu,
.dropdown-menu:has(:focus-visible) {
display: block;
opacity: 1;
transform: translateY(0);
}O @starting-style repete os mesmos valores do estado "fechado". Isso parece redundante, mas não é: sem ele, o browser não sabe que precisa animar a entrada. Os valores em @starting-style são o frame zero exclusivo da transição de entrada. Os valores no seletor base (.dropdown-menu) são o estado de saída: quando o elemento perde a condição de visibilidade, ele transiciona de opacity: 1 para opacity: 0 e, ao final, display muda para none.
Suporte de browsers e estratégia de fallback
/* fallback.css */
/* Estratégia: o elemento funciona sem animação em browsers antigos.
A funcionalidade (abrir/fechar) não depende de @starting-style. */
.notification {
display: none;
opacity: 1; /* Fallback: aparece sem animação */
}
.notification.active {
display: flex;
}
/* Progressive enhancement: browsers que suportam @starting-style
ganham a animação */
@supports (transition-behavior: allow-discrete) {
.notification {
opacity: 0;
transition:
opacity 250ms ease-out,
display 250ms ease-out allow-discrete;
@starting-style {
opacity: 0;
}
}
.notification.active {
opacity: 1;
}
}Chrome 117+, Edge 117+ e Safari 17.4+ suportam @starting-style. Firefox 129+ também. Para projetos que precisam suportar Firefox abaixo de 129 ou Safari abaixo de 17.4, o @supports garante que o fallback é funcional: o elemento aparece e desaparece sem animação, mas sem quebrar.
Quando usar @starting-style vs. @keyframes
| Critério | @starting-style + transition | @keyframes + animation |
|---|---|---|
| Animação de entrada | Sim, com @starting-style | Sim, com animation |
| Animação de saída | Sim, com allow-discrete | Exige JS para adicionar classe de saída e escutar animationend |
| Interrupção suave | Transitions revertem naturalmente | Animations precisam de lógica extra |
| Complexidade (multi-step) | Não suporta keyframes intermediários | Suporta N steps |
| Performance | Composited se usar opacity/transform | Composited se usar opacity/transform |
Para animações de entrada/saída simples (fade, slide, scale), @starting-style é a escolha certa. Para animações complexas com múltiplos passos (bounce, shake, sequências), @keyframes continua sendo necessário. Os dois podem coexistir: use @starting-style para a transição de display e @keyframes para a animação visual dentro do elemento já visível.
Essa abordagem de CSS nativo assumindo responsabilidades que antes eram exclusivas de JavaScript se alinha com a tendência mais ampla de Scroll-Driven Animations e CSS nativo cobrindo lacunas do ecossistema JS e Container Queries eliminando JavaScript de lógica responsiva.
FAQ
@starting-style funciona com @keyframes?
Não. @starting-style define o ponto de partida para transições CSS, não para animações @keyframes. Se você precisa de animações multi-step na entrada, use @keyframes com animation normalmente. @starting-style é especificamente para o caso de transição de display: none para visível.
Preciso de @starting-style se o elemento nunca tem display: none?
Depende. Se o elemento já está no DOM e visível, e você só altera opacity/transform via classes, transições normais funcionam sem @starting-style. Ele só é necessário quando o elemento é inserido no DOM ou muda de display: none para um valor visível, porque nesses casos o navegador não tem estado anterior para interpolar.
Como testar o suporte no browser?
Use @supports:
@supports (transition-behavior: allow-discrete) {
/* Estilos que dependem de @starting-style e allow-discrete */
}Em JavaScript: CSS.supports('transition-behavior', 'allow-discrete') retorna true em browsers compatíveis.
@starting-style substitui Framer Motion / React Transition Group?
Para animações de entrada/saída simples (fade, slide, scale), sim. Para animações complexas com layout animations, shared element transitions, drag gestures ou spring physics, essas bibliotecas ainda têm espaço. A decisão é direta: se a animação é uma transição entre dois estados CSS declaráveis, @starting-style resolve. Se envolve cálculo dinâmico de posição ou física, use uma lib.
Posso usar @starting-style dentro de nesting CSS nativo?
Sim. A sintaxe aninhada funciona em browsers que suportam ambas as features:
.panel {
display: none;
opacity: 0;
transition: display 300ms allow-discrete, opacity 300ms ease-out;
&.active {
display: grid;
opacity: 1;
}
@starting-style {
opacity: 0;
}
}A plataforma está absorvendo o JavaScript de apresentação
@starting-style não é uma feature isolada. Ela faz parte de um movimento maior: scroll-driven animations, container queries, anchor positioning, view transitions. Cada uma dessas APIs remove uma categoria inteira de JavaScript que existia apenas para compensar limitações do CSS.
A implicação prática para quem mantém aplicações React ou Next.js é que o bundle de animação pode encolher. Framer Motion adiciona cerca de 30-40kB gzipped ao bundle. Se 80% do uso é fade-in/fade-out de modais e toasts, @starting-style elimina essa dependência. O JavaScript que sobra é o que realmente precisa ser JavaScript: lógica de negócio, orquestração de estado, comunicação com APIs.
Adotar essas APIs CSS nativas não é minimalismo estético. É uma decisão de engenharia: menos código para manter, menos bugs de timing entre JS e rendering pipeline, menos superfície de ataque para problemas de segurança em dependências. O CSS que o browser executa nativamente é mais rápido, mais previsível e não precisa de teste unitário.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

Animações de Scroll com CSS Puro: animation-timeline e scroll() na Prática

Scroll-Driven Animations, corner-shape e CSS Nativo: Cobrindo as Lacunas que o Ecossistema JS Criou

CSS3, CSS4 ou CSS5? Qual é a versão atual do CSS em 2026
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.