Ir para o conteúdo
React

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

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de paineis de vidro em transicao de invisivel para visivel representando CSS starting-style
Ouça este artigo
0:00Dominando @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.

CSS
/* 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.

CSS
.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:

CSS
.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:

CSS
.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 é:

  1. JS adiciona a classe is-visible.
  2. O navegador detecta que display mudou de none para flex.
  3. @starting-style é consultado: opacity começa em 0, transform começa em translateX(100%).
  4. A transição interpola até opacity: 1 e translateX(0).
  5. Quando JS remove is-visible, os valores voltam para o estado fechado.
  6. display: none só é aplicado quando opacity e transform terminam de transicionar.

O JavaScript necessário se reduz a uma linha:

JavaScript
// 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.

HTML
<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>
CSS
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

CSS
/* 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. */
}
CSS
/* 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

CSS
/* 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 */
}
CSS
/* 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:

CSS
/* 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

AspectoAbordagem JS clássicaCSS com @starting-style
Animação de entradarequestAnimationFrame + classe@starting-style + transition
Animação de saídalistener transitionend + display: noneallow-discrete resolve automaticamente
Linhas de JS15-30 (sem lib)1-2 (toggle de classe ou atributo)
Dependência de libFramer Motion, GSAP, React Transition GroupNenhuma
Funciona com <dialog>Precisa interceptar close eventNativo
Funciona com Popover APIPrecisa de polyfill de animaçãoNativo
Suporte a browsersTodosChrome 117+, Safari 17.5+, Firefox 129+
Fallback quando sem suporteN/AElemento 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:

TSX
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;
CSS
.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:

  1. O estado fechado do elemento usa display: none? Se sim, você precisa de allow-discrete na transição de display.
  2. O elemento participa da top layer (<dialog>, popover)? Adicione overlay à lista de transições.
  3. O @starting-style declara os mesmos valores do estado fechado? Se os valores divergirem, a animação de entrada terá um "salto" visível.
  4. O fallback é aceitável? Em browsers sem suporte, o elemento aparece/desaparece sem animação. Se isso é inaceitável, mantenha o JS como fallback.
  5. A duração da transição de display é igual ou maior que a das outras propriedades? Se display transicionar mais rápido, o elemento desaparece antes de opacity/transform completarem.

Animação de entrada com transform: slide + fade

CSS
/* 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

CSS
/* 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 entradaSim, com @starting-styleSim, com animation
Animação de saídaSim, com allow-discreteExige JS para adicionar classe de saída e escutar animationend
Interrupção suaveTransitions revertem naturalmenteAnimations precisam de lógica extra
Complexidade (multi-step)Não suporta keyframes intermediáriosSuporta N steps
PerformanceComposited se usar opacity/transformComposited 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:

CSS
@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:

CSS
.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.

Marcos Soares

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

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.