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

As durações do app inteiro. Se um gesto novo não cabe em nenhuma delas, o gesto é que está errado.
DuraçãoUsoReceita
100msPopups: menu, tooltip, modal, popover, a janela do compromisso e a lista do comboboxanimate-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
300ms / 250msGaveta: a linha vira ficha no lugarcubic-bezier(0.32, 0.72, 0, 1), a curva do painel lateral — ver abaixo
300msPainel lateral entrando pela direitaanimate-sheet-in (slide, cubic-bezier(0.32, 0.72, 0, 1) — a curva do sheet do iOS)
250msPainel lateral saindo pela direitaanimate-sheet-out (slide, mesma curva, ao contrário)
300ms / 250msFolha 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 / 250msScrim atrás de painel ou folha acendendo e apagandoanimate-scrim-in (ease-out) · animate-scrim-out (ease-in)
150msBarra de ações em massa, por seleçãoanimate-bar-in (sobe 0.5rem + fade, ease-out)
200msLista que muda: quem fica corre pro lugar novouseListaQueAnda (FLIP por Element.animate)
300ms / 250msGaveta deitada: controles que entram numa linha.gaveta-deitada — grid-template-columns 0fr → 1fr
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á. Duas exceções: 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) — e o painel lateral, que a partir de 17/09 também sai pelo mesmo trajeto da entrada, ao contrário (decisão do founder: clique fora dele fecha o painel, e merece o mesmo gesto de volta).

Os keyframes

Nove 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 { /* 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

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/gaveta.tsx · Gaveta + EditSurface
globals.css
.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

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

Excluir um insumo, renomear um que muda de lugar na ordem, arquivar um número, trocar a ordenação dos cadastros: a lista velha some e a nova aparece no mesmo quadro, e quem ficou está noutro lugar sem ter ido até lá.
ui/lista-que-anda.ts
// 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

Em uso nos cadastros (tabela e cards do celular), na grade de Números e na lista de Insumos. Os outros blocos de Ajustes ficaram de fora porque a linha deles é um componente com DOIS nós raiz possíveis (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

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.