Pular para o conteúdo
Movimento

Fundações

Movimento

Calmo e rápido. Movimento aqui não é enfeite: ele explica o que aconteceu com a tela — o que abriu, o que virou o quê, o que ainda está vivo. Nada dura mais que 300ms, e tudo respeita prefers-reduced-motion.
globals.css100–300msmotion-reduce é parte da regra

Durações

Seis durações no app inteiro. Se um gesto novo não cabe em nenhuma delas, o gesto é que está errado.
DuraçãoUsoReceita
100msPopups: menu, tooltip, modal, popoveranimate-popup-in (fade + zoom 0.95 → 1, ease-out)
120–200msHover de cor, borda, elevaçãotransition-colors · transition-[border-color,box-shadow] duration-200
150msCard acendendo/apagando a borda[transition:border-color_150ms,box-shadow_150ms]
200msSidebar recolhendo pro modo trilhotransition-[width] duration-200 ease-out
200msGaveta: a linha vira ficha no lugarcubic-bezier(0.45, 0, 0.35, 1) — ver abaixo
300msFoco em camadas: a tela recua e o bloco sobetransition: opacity 300ms, background-color 300ms, box-shadow 300ms
padrão (150ms)Barra de progresso preenchendotransition-[width] — sem duração declarada, como no orçamento da base de conhecimento
1.25s loopCursor piscando do OTPanimate-caret-blink
~2s loopPulso de status vivo e esqueletoutilities pulso e skeleton

NotaSaída seca, de propósito

Popups ENTRAM com fade + zoom e saem no corte. Quem fechou um menu já decidiu; animar a saída atrasa a próxima ação e faz o clique seguinte cair num painel que ainda está lá. A exceção é a gaveta, cuja saída é o mesmo gesto ao contrário — porque ali a saída também é uma transformação (a ficha volta a ser linha).

Os keyframes

Três animações declaradas em globals.css. Só isso.
globals.css
@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

O keyframe da gaveta lateral existe em 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

O gesto mais elaborado do produto — e o único que valeu uma máquina de estados. Clique em “Editar”: a linha não some pra dar lugar a um formulário; ela VIRA o formulário.
Luva de procedimentoem 5 fichasR$ 0,24/un
a peça real: ui/crud-block.tsx · Gaveta + EditSurface
globals.css
.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

O JS precisa do número pra segurar o conteúdo montado enquanto o vão fecha (desmontar na hora é o que fazia a edição sumir no corte). A constante 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

Duas utilities irmãs. Elas existem porque o par “animação + prefers-reduced-motion” era copiado classe a classe — e o pedaço esquecido era justo o segundo.
skeleton = animate-pulse + bg-ink-100 + motion-reduce:animate-none

Agente atendendo agora

Confirmação em curso

Parado (sem pulso)

pulso = animate-pulse + motion-reduce:animate-none, sem tinta — pra quem já tem cor
globals.css
@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

Utility composto sai antes no CSS. Se você escrever 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

Não é bônus: é parte de cada regra. Quem liga “reduzir movimento” no sistema operacional tem motivo — enxaqueca vestibular é o mais comum.
OndeO que acontece com reduced-motion
utilities pulso / skeletonanimation: none — o bloco fica parado, na mesma tinta.
animate-popup-inmotion-reduce:animate-none no consumidor: o painel aparece pronto.
A gavetatransition: none nas duas classes; o vão troca seco e o conteúdo continua correto.
Foco em camadastransition: none nas cinco camadas; o recuo acontece, sem interpolar.
Card acendendomotion-reduce:[transition:none].
FormAlert rolando até a vistascrollIntoView com behavior 'auto' em vez de 'smooth'.
a leitura em JS, quando o CSS não alcança
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
ref.current?.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });

O que NÃO se anima

Transição preguiçosa custa resposta tátil.

transition-all é proibido em controle

O botão lista o que DEVE animar e exclui o resto:
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 animarPor quê
Altura de conteúdo em pxExige medir; a gaveta resolve com grid-template-rows.
Aparecimento de erro de formulárioO 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údoO conteúdo entra no lugar; fade duplicado faz a tela piscar.
FocoO anel aparece imediatamente — atraso ali é acessibilidade, não estilo.