Fundações
Movimento
prefers-reduced-motion.Durações
| Duração | Uso | Receita |
|---|---|---|
| 100ms | Popups: menu, tooltip, modal, popover | 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 |
| 200ms | Gaveta: a linha vira ficha no lugar | cubic-bezier(0.45, 0, 0.35, 1) — ver abaixo |
| 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 { /* gaveta lateral: desliza da direita */
from { transform: translateX(100%) }
to { transform: translateX(0) }
}
@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-caret-blink: caret-blink 1.25s ease-out infinite;Notasheet-in está declarado e não é consumido
globals.css desde o desenho original, mas nenhuma peça do app usa hoje — o dock do agente aparece sem deslizar. Está aqui porque é canon declarado, não porque está em uso: antes de escrever uma gaveta nova, ou você usa este keyframe ou ele sai do arquivo.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.
A gaveta
.gaveta {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms cubic-bezier(0.45, 0, 0.35, 1);
}
.gaveta-miolo {
opacity: 0; transform: scale(0.985);
transition: opacity 200ms cubic-bezier(...), transform 200ms cubic-bezier(...);
}
.gaveta-aberta { grid-template-rows: 1fr }
.gaveta-aberta .gaveta-miolo { opacity: 1; transform: none }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.
Armadilha já pagaDuração espelhada em dois lugares
GAVETA_MS = 200 em crud-block.tsx espelha o CSS: mudar num lugar é mudar no outro. E abrir exige dois requestAnimationFrame — o primeiro entrega o vão fechado ao navegador, o segundo é que abre; num só, estilo inicial e final caem no mesmo cálculo e a transição não chega a existir.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. |