Fundações
Movimento
prefers-reduced-motion.Durações
| Duração | Uso | Receita |
|---|---|---|
| 100ms | Popups: menu, tooltip, modal, popover, a janela do compromisso e a lista do combobox | animate-popup-in (fade + zoom 0.95 → 1, ease-out) |
| 120–200ms | Hover de cor, borda, elevação | transition-colors · transition-[border-color,box-shadow] duration-200 |
| 150ms | Card acendendo/apagando a borda | [transition:border-color_150ms,box-shadow_150ms] |
| 200ms | Sidebar recolhendo pro modo trilho | transition-[width] duration-200 ease-out |
| 300ms / 250ms | Gaveta: a linha vira ficha no lugar | cubic-bezier(0.32, 0.72, 0, 1), a curva do painel lateral — ver abaixo |
| 300ms | Painel lateral entrando pela direita | animate-sheet-in (slide, cubic-bezier(0.32, 0.72, 0, 1) — a curva do sheet do iOS) |
| 250ms | Painel lateral saindo pela direita | animate-sheet-out (slide, mesma curva, ao contrário) |
| 300ms / 250ms | Folha de baixo no celular (balcão, conversa de teste, dados da mensagem) | animate-sheet-up-in / animate-sheet-up-out (o mesmo gesto, vindo do rodapé) |
| 300ms / 250ms | Scrim atrás de painel ou folha acendendo e apagando | animate-scrim-in (ease-out) · animate-scrim-out (ease-in) |
| 150ms | Barra de ações em massa, por seleção | animate-bar-in (sobe 0.5rem + fade, ease-out) |
| 200ms | Lista que muda: quem fica corre pro lugar novo | useListaQueAnda (FLIP por Element.animate) |
| 300ms / 250ms | Gaveta deitada: controles que entram numa linha | .gaveta-deitada — grid-template-columns 0fr → 1fr |
| 300ms | Foco em camadas: a tela recua e o bloco sobe | transition: opacity 300ms, background-color 300ms, box-shadow 300ms |
| padrão (150ms) | Barra de progresso preenchendo | transition-[width] — sem duração declarada, como no orçamento da base de conhecimento |
| 1.25s loop | Cursor piscando do OTP | animate-caret-blink |
| ~2s loop | Pulso de status vivo e esqueleto | utilities pulso e skeleton |
NotaSaída seca, de propósito
Os keyframes
@keyframes popup-in { /* menu, tooltip, modal */
from { opacity: 0; transform: scale(0.95) }
to { opacity: 1; transform: scale(1) }
}
@keyframes sheet-in { /* painel lateral: desliza da direita */
from { transform: translateX(100%) }
to { transform: translateX(0) }
}
@keyframes sheet-out { /* painel lateral: o mesmo trajeto, ao contrário */
from { transform: translateX(0) }
to { transform: translateX(100%) }
}
@keyframes sheet-up-in { /* folha de baixo (celular): sobe do rodapé */
from { transform: translateY(100%) }
to { transform: translateY(0) }
}
@keyframes sheet-up-out { /* ...e desce pelo mesmo caminho */
from { transform: translateY(0) }
to { transform: translateY(100%) }
}
@keyframes scrim-in { /* o escuro atrás do painel ou da folha acende... */
from { opacity: 0 }
to { opacity: 1 }
}
@keyframes scrim-out { /* ...e apaga no mesmo compasso do painel */
from { opacity: 1 }
to { opacity: 0 }
}
@keyframes bar-in { /* barra de ações que aparece por seleção */
from { opacity: 0; transform: translateY(0.5rem) }
to { opacity: 1; transform: none }
}
@keyframes caret-blink { /* o cursor falso do campo de código */
0%, 70%, 100% { opacity: 1 }
20%, 50% { opacity: 0 }
}
/* No @theme, viram utilities: */
--animate-popup-in: popup-in 100ms ease-out;
--animate-sheet-in: sheet-in 300ms cubic-bezier(0.32, 0.72, 0, 1);
--animate-sheet-out: sheet-out 250ms cubic-bezier(0.32, 0.72, 0, 1) forwards;
--animate-sheet-up-in: sheet-up-in 300ms cubic-bezier(0.32, 0.72, 0, 1);
--animate-sheet-up-out: sheet-up-out 250ms cubic-bezier(0.32, 0.72, 0, 1) forwards;
--animate-scrim-in: scrim-in 300ms ease-out;
--animate-scrim-out: scrim-out 250ms ease-in forwards;
--animate-bar-in: bar-in 150ms ease-out;
--animate-caret-blink: caret-blink 1.25s ease-out infinite;NotaOnde o painel lateral entra e sai
animate-sheet-in/animate-sheet-out vestem os dois painéis de 512px que encostam na direita: a ficha da pessoa do mural da equipe e o painel de serviço da precificação. Entram e saem deslizando, sem fade no painel — quem faz fade é o scrim atrás dele, com animate-scrim-in/animate-scrim-out no Overlay, no mesmo compasso. É a receita do sheet do iOS: a curva cubic-bezier(0.32, 0.72, 0, 1) arranca na hora do clique e gasta a segunda metade do tempo assentando, nos dois sentidos. A saída é mais curta (250ms contra 300ms) porque quem fechou já decidiu. Uma curva ease-in na saída foi testada e recusada em 17/09: o painel ficava parado ~80ms e depois sumia num pulo. O data-[state=closed]: do Radix troca a classe assim que o painel começa a fechar, e o Radix Presence só desmonta Overlay e Content quando a animação de saída termina. Os 300ms não são os 100ms do popup porque o trajeto é a largura de um painel, não um zoom de 5% — mais curto que isso ele chega como um susto. Quem escrever o terceiro painel usa as mesmas quatro utilities.O animate-popup-in anda junto do origin-(--radix-dropdown-menu-content-transform-origin) nos menus: o zoom nasce do canto por onde o painel abriu, não do centro. Fora do Radix, a origem é escrita na mão — a lista do combobox usa origin-top, que é a borda do campo de onde ela desce.
Armadilha já pagaQuem se move por transform não anima por transform
popup-in e bar-in mexem em transform — e é em transform que moram o -translate-x-1/2 de quem centra e o sm:-translate-y-1/2 da janela do compromisso. No mesmo nó, um apaga o outro e a peça entra correndo de um lugar onde ela nunca esteve. O remédio é sempre o mesmo: um nó posiciona, o de dentro se mexe. É assim na barra de ações em massa, na janela do compromisso e na bolha de criar.A gaveta
.gaveta {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms cubic-bezier(0.32, 0.72, 0, 1);
}
.gaveta-miolo {
opacity: 0; transform: scale(0.985);
transition: opacity 250ms cubic-bezier(...), transform 250ms cubic-bezier(...);
}
.gaveta-aberta { grid-template-rows: 1fr; transition-duration: 300ms }
.gaveta-aberta .gaveta-miolo { opacity: 1; transform: none; transition-duration: 300ms }Ninguém mede pixel
O vão vive em grid-template-rows: 0fr → 1fr. Onde o navegador não interpola grid, o vão troca seco e o fade continua: degrada pro efeito simples, nunca pra layout quebrado.
É transição de ESTADO, não @keyframes
A saída é o mesmo gesto ao contrário e pode ser interrompida no meio — reabrir durante o fechamento reverte de onde estava. A versão antiga, com keyframes, fechava no corte.
transform: none, nunca scale(1)
Transform parado criaria containing block e cortaria a sombra de elevação do tapete em foco.
O recorte só existe enquanto o vão se mexe
overflow-hidden sai no fim da transição — e sai também quando não há transição nenhuma (reduced-motion), onde o transitionend nunca chega.
NotaA mesma gaveta, deitada
<Gaveta eixo="horizontal"> abre o vão pra LADO, em grid-template-columns, com a mesma duração e a mesma curva. É o que os anexos do compositor usam: três botões entrando de uma vez empurravam o campo de texto 132px de lado sem aviso. Medido em réplica headless — fechada 0px, aberta 132px, campo empurrado os mesmos 132px.Armadilha já pagaDuração espelhada em dois lugares
GAVETA_ABRE_MS = 300 e GAVETA_FECHA_MS = 250 em ui/gaveta.tsx espelham o CSS: mudar num lugar é mudar no outro. Abrir exige dois requestAnimationFrame (o primeiro entrega o vão fechado ao navegador, o segundo é que abre), e FECHAR espera os mesmos dois: quando a linha vira ficha, as duas gavetas cruzam, e a que fechava saía dois quadros antes da que abria. A lista de baixo subia uns 16px e depois descia, o tranco medido quadro a quadro.A lista que anda
// no container da lista
const listaRef = useRef<HTMLDivElement>(null);
useListaQueAnda(listaRef, itens.map((i) => i.id).join(","));
// em cada item, o id do BANCO — nunca o índice
<div data-anda={item.id}>…</div>Quem fica corre, quem chega acende
Trajeto por FLIP (mede antes, translada, solta) para quem continua na lista; opacity de 0 a 1 para quem entrou — de onde ele viria, se antes não existia?
A saída é seca
Quem saiu já foi. Segurar o item montado pra animá-lo adiaria a lista nova, e é a mesma regra dos popups (§06).
Quem atravessa a tela não ganha trajeto
Acima de TETO_DO_TRAJETO (240px, quatro linhas) o item aparece direto no lugar novo: numa lista filtrada de 50 pra 8, a linha 47 vira a 3ª, e o olho não segue isso — lê como bagunça.
A chave é a lista, não o render
O gatilho é a assinatura dos ids em ordem. Sem ela, digitar na busca dispararia o FLIP a cada tecla. Mudança de LARGURA descarta a foto: ali o layout é outro.
NotaOnde está, e onde ainda não
EditSurface ou CrudRowShell): dar o data-anda a eles é uma prop nova no primitivo, e embrulhar a linha por fora quebraria o first:pt-0 last:pb-0 da linha de modelo de contrato.Pulso e esqueleto
Agente atendendo agora
Confirmação em curso
Parado (sem pulso)
@utility skeleton { @apply animate-pulse bg-ink-100 motion-reduce:animate-none }
@utility pulso { @apply animate-pulse motion-reduce:animate-none }Armadilha já pagaNão combinar com outro animate-* na mesma classe
skeleton animate-bounce, o animate-* de fora vence o animation: none do reduced-motion e a regra de acessibilidade some sem aviso.Quando pulsar: coisa viva AGORA — agente atendendo, confirmação em curso, busca ativa, oportunidade que ainda responde. Nunca decoração; um dot que pulsa sem motivo treina o olho a ignorar o dot que importa.
O contrato com prefers-reduced-motion
| Onde | O que acontece com reduced-motion |
|---|---|
| utilities pulso / skeleton | animation: none — o bloco fica parado, na mesma tinta. |
| animate-popup-in | motion-reduce:animate-none no consumidor: o painel aparece pronto. |
| A gaveta | transition: none nas duas classes; o vão troca seco e o conteúdo continua correto. |
| Foco em camadas | transition: none nas cinco camadas; o recuo acontece, sem interpolar. |
| Card acendendo | motion-reduce:[transition:none]. |
| FormAlert rolando até a vista | scrollIntoView com behavior 'auto' em vez de 'smooth'. |
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
ref.current?.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });O que NÃO se anima
transition-all é proibido em controle
transition-[color,background-color,border-color,box-shadow,opacity] active:translate-y-px /* ← fora da lista de propósito */
O active:translate-y-px é resposta tátil imediata: animá-lo atrasa o toque em 150ms e o botão passa a parecer “molenga”. A mesma lógica vale pra transform em geral — anima-se cor, sombra e opacidade; posição só quando a posição É o gesto (gaveta, barra em massa, sheet).
| Nunca animar | Por quê |
|---|---|
| Altura de conteúdo em px | Exige medir; a gaveta resolve com grid-template-rows. |
| Aparecimento de erro de formulário | O aviso precisa estar lá quando o olho chega. |
| Números de KPI subindo (count-up) | Métrica de clínica não é show; o valor certo é o valor final. |
| Skeleton virando conteúdo | O conteúdo entra no lugar; fade duplicado faz a tela piscar. |
| Foco | O anel aparece imediatamente — atraso ali é acessibilidade, não estilo. |