Pular para o conteúdo
Vazio, carregando & erro

Componentes

Vazio, carregando & erro

Os três estados que a maioria dos sistemas trata como sobra. Aqui eles seguem o princípio dos estados calmos: informam o próximo passo e nunca alarmam. Erro é um painel discreto com “tentar de novo”, não uma tela vermelha.
ui/empty-state.tsxui/skeleton-blocos.tsxui/form-feedback.tsxui/sonner.tsx

Vazio

Duas variantes: o vazio de ONBOARDING (nunca teve nada) e o vazio de FILTRO (tem, mas não com esse recorte). Eles não dizem a mesma coisa e não oferecem o mesmo botão.

Nenhum paciente ainda

Conecte o WhatsApp ou importe sua base pra começar a organizar a agenda.

onboarding: ícone + título + próximo passo + CTA primário

Nenhum paciente com esse filtro

Tente outro termo ou volte pra lista completa.

filtro: sem ícone, respiro menor, CTA outline que LIMPA o filtro
PropTipoPadrãoO que faz
iconLucideIcon—Opcional. Com ícone, o gap sobe pra 4 e nasce o selo circular ink-100 de 56px; sem ícone, gap-3 (é a variante de filtro).
titlestring—Uma frase curta, 16/500.
descriptionstring—O próximo passo, em max-w-xs. Não descreve o vazio: aponta a saída.
childrenReactNode—As ações. Primária no onboarding, outline no filtro.
densidade"tela" | "bloco"—tela (padrão) é o vazio de uma tela ou lista. bloco é o vazio DENTRO de um cartão (Ajustes, Precificação): título 14px, texto 12px, respiro phi-3, selo de ícone opcional. É o que o CrudEmptyState veste por baixo do CTA dele.
PeçaReceita
Moldurarounded-xl border border-dashed border-border
Respiropy-phi-4 (fluido)
Selo do íconesize-14 rounded-full bg-ink-100 · ícone size-6 em fg-subtle
Textocentrado, description em max-w-xs

NotaA borda tracejada é semântica

Ela diz “aqui vai entrar coisa”. É o único uso de tracejado no produto — nem card, nem upload, nem separador usam. Por isso, quando aparece, o olho já sabe o que significa.

Carregando

O cabeçalho REAL fica no lugar e só o conteúdo vira esqueleto. Assim a tela não pula quando os dados chegam — e a pessoa já sabe onde está.
SkeletonBlocos: a pilha de cards das telas de rail. O pulso e a tinta vêm do utility skeleton; aqui é só a forma.
RegraDetalhe
O esqueleto imita a FORMA realCampos são pílulas no app, então o esqueleto deles é rounded-full. Retângulo no lugar de pílula faz a tela “trocar de roupa” quando carrega.
Nunca um spinner no meio do nadaSpinner só dentro de um botão que está trabalhando (o ícone Loader2 do toast de loading).
aria-busy e role=statusA região carregando se anuncia: role="status" aria-busy="true" aria-label="Carregando".
loading.tsx embrulha a PAGE, nunca o layout do mesmo segmentoUm await no layout faz o boundary de cima piscar antes do palco aparecer.
Sem fade de entradaO conteúdo entra no lugar do esqueleto. Fade duplicado faz a tela piscar.
o utility, e a fronteira dele
@utility skeleton { @apply animate-pulse bg-ink-100 motion-reduce:animate-none }

/* NÃO combinar com outro animate-* na mesma classe: utility composto sai antes
   no CSS, então o animate-* de fora venceria o animation:none do reduced-motion. */
<div className="skeleton h-9 w-full rounded-full" /> ✓
<div className="skeleton animate-bounce" /> ✗

Erro

Três alturas de erro: a rota inteira falhou, o formulário recusou, o campo está errado. Nenhuma delas culpa quem está na frente da tela.

Algo deu errado

A tela não conseguiu carregar. Tente de novo. Se continuar, recarregue a página.

código a1b2c3

RouteError: selo negative-soft + copy calma + código do digest + retry

Verificação em duas etapas ativa nesta conta.

FormAlert (erro geral do form) e PositiveBanner (a contraparte calma)
AlturaPeçaComportamento
A rota falhouRouteError (error.tsx)Selo, copy calma, o digest do erro em algarismo tabular e um retry que faz router.refresh() + reset() (RouteErrorBoundary, o miolo dos três error.tsx).
O formulário recusouFormAlertrole="alert" + rolagem até ficar visível quando o erro SURGE ou MUDA — em form longo no celular ele nascia acima da dobra e passava batido.
O campo está erradoFieldError12px destructive, ligado ao input por aria-describedby, e some ao primeiro toque.
Deu certotoast.success / PositiveBannerToast pro que acabou de acontecer; banner pro estado que permanece.

Sim

Diz o que houve e dá o caminho. Painel discreto, sem ícone de alarme, sem borda vermelha grossa.

Não

ERRO 422: UNPROCESSABLE ENTITY

Vermelho cheio, caixa alta e vocabulário de servidor. Assusta e não ajuda: quem marca consulta não conhece código HTTP.

Toast

Confirmação do que ACABOU de acontecer, no canto, sem interromper. Clique e veja: são os toasts reais do app.
Sonner com richColors, ícones Lucide (nunca emoji) e raio calc(--radius + 4)
RegraDetalhe
Título curto + descrição opcional“Consulta confirmada” + “Marina Alvez confirmou para amanhã, 14h.” O título não leva ponto nem exclamação; a frase inteira vai na descrição, com ponto. Erro fica de fora: costuma ser a frase que o servidor devolveu.
O tipo diz o que aconteceusuccess = um gesto terminou e o servidor confirmou, inclusive desligar, arquivar e excluir. info = aviso que não é resultado de gesto (“Nada pra exportar”). warning = resultado parcial ou limite (“Máximo de 500 marcados”). error = falha. Neutro, só com Desfazer.
Cor é token, nunca a paleta do SonnerNeutro = surface, fg e border (o mesmo chão do menu e do modal). Sucesso, erro, aviso e informação = texto -strong sobre fundo -soft, o par que contraste.test.ts mede nos cinco temas. Fonte = a Inter do body. Tudo em ui/sonner.tsx; a descrição do neutro, em globals.css.
O tema vem por propO root layout lê o cookie e passa; nunca useTheme/next-themes — sem provider o hook cai em “system” e o toast herdaria o escuro do sistema num app claro.
Salvar sozinho confirma pela forma da telaTela em fileiras de campo (a ficha da clínica) confirma no marcador do próprio campo, sem toast. A ficha da pessoa, por decisão, não confirma: grava ao sair do campo e só fala quando o campo é recusado, no próprio campo. Tela de cartões ou de escolhas que gravam sozinhas (Precificação, Configurações da agenda) chama avisarSalvo (src/lib/aviso.ts): um “Salvo” curto, de id fixo, que se atualiza no lugar em vez de empilhar.
Toast não substitui estadoSe a mudança fica na tela (o item sumiu da lista), o toast é confirmação; se não fica, a informação precisa de outro lugar.
Desfazer vive dentro do toastNeutro, e não verde: o feito ainda pode voltar. Dura DESFAZER_MS (src/lib/aviso.ts), a mesma janela no app inteiro. Dois jeitos, conforme o gesto: gravar só quando o toast se despede (onAutoClose/onDismiss, com uma flag de desfeito; o timer do Sonner PAUSA no hover, então um setTimeout paralelo comete por baixo do toast), ou gravar na hora e o Desfazer chamar o gesto contrário (avisarComDesfazer, da agenda). toast.test.ts confere as quatro regras.
Erro no toast só pro que é passageiroFalha de envio, timeout. Erro de formulário mora no FormAlert, perto do campo.

Um toast.loading pode virar sucesso reusando o mesmo id (o botão acima demonstra). Hoje nenhuma tela do app usa esse caminho — as ações longas desabilitam o botão e trocam o rótulo (“Salvando…”). Se for usar, lembre que ele ocupa a fila de toasts durante toda a espera.

A copy dos três estados

A forma está pronta; o que decide se o estado é calmo é o texto.

Fórmulas

EstadoFórmulaExemplo
Vazio (onboarding)O que falta + o próximo passo“Nenhum paciente ainda. Conecte o WhatsApp e comece a organizar a agenda.”
Vazio (filtro)O que não bateu + como voltar“Nenhum paciente com esse filtro. Tente outro termo ou volte pra lista completa.”
ErroO que houve + o que fazer (+ o código, discreto)“A tela não conseguiu carregar. Tente de novo. Se continuar, recarregue a página.”
SucessoO que aconteceu, com o objeto“207 cadastros importados.”

NuncaNunca culpar quem está lendo

“Você não cadastrou nada”, “Você preencheu errado”, “Você precisa selecionar”. A tela erra junto: “Nenhum paciente ainda”, “Número inválido. Use DDD + 9 dígitos”, “Escolha ao menos um horário”.