← Blog

Os estados que o shadcn/ui deixa por sua conta: carregando, sucesso, erro, vazio e movimento reduzido

O shadcn/ui te dá um botão limpo, não um botão de carregamento. Os estados difíceis de UI que todo projeto shadcn faz à mão, com os tempos que fazem com que pareçam certos.

Preet Suthar · · 5 min de leitura

  • React
  • shadcn/ui
  • UX
  • Accessibility

Pesquise "shadcn button loading state" e você vai encontrar cem snippets que fazem a mesma coisa: adicionam uma prop isLoading, trocam o rótulo por um spinner, desabilitam o botão. Publique isso e o seu app vai parecer pior do que antes de você adicionar.

Isso não é uma crítica ao shadcn/ui. Ele te dá um ponto de partida limpo e deixa os estados por sua conta, de propósito. Mas "os estados" são onde mora a maior parte da sensação de uma interface, e quase ninguém reserva tempo para eles. Esta é a lista de estados que eu trato em todo componente que publico, e os tempos específicos que separam "finalizado" de "funciona".

1. Carregamento que não pisca

O botão de carregamento ingênuo mostra um spinner no instante em que você clica. A maioria das requisições termina em menos de 200ms, então o que o usuário vê é um flash de spinner e depois o resultado. Parece tremedeira.

Duas regras resolvem:

  • Espere antes de mostrar o spinner. Se a requisição termina em cerca de 150ms, nunca o mostre.
  • Depois de mostrado, mantenha-o por um tempo mínimo. Cerca de 400ms. Um spinner que aparece por 30ms é pior do que nenhum spinner.
const spinnerDelay = 150
const minimumSpinnerTime = 400

Isso é um hook, não uma prop. Este é o formato:

const { buttonProps, track } = useButtonFeedback()

<Button {...buttonProps} onClick={() => track(save())}>
  Save
</Button>

track recebe a promise. O hook decide se a requisição foi rápida o bastante para pular o spinner, segura o spinner por tempo suficiente para não piscar e leva o botão para sucesso ou erro quando a promise é resolvida. O hook useButtonFeedback do HextaUI e o useDelayedLoading fazem exatamente isso, e são pequenos o bastante para ler de uma vez só.

A mesma regra de atraso vale para skeletons. Um skeleton que aparece por 50ms e é substituído pelo conteúdo é um flash cinza. Atrase-o em 150ms e, quando o conteúdo chegar, faça um fade in sem mover nada ao redor.

2. Sucesso e erro, no lugar

Depois que um salvamento dá certo, o botão deve dizer isso. "Saved" com um check, por cerca de dois segundos, depois de volta ao estado ocioso. Depois que falha, o botão também deve dizer isso, e segurar o erro por mais tempo, cerca de quatro segundos, porque as pessoas precisam de tempo para ler um erro e decidir o que fazer.

Dois detalhes que importam mais do que parecem:

  • Não redefina enquanto o ponteiro está sobre o botão. Se alguém está com o mouse em cima, está lendo. Redefina depois que sair.
  • Ignore cliques enquanto a requisição está em andamento. Não com disabled, que tira o botão da ordem de tabulação e faz os leitores de tela pularem por ele. Use aria-busy e ignore o clique.

Um Switch tem o mesmo problema em um espaço menor. Alterne-o, envie a requisição e, se a requisição falhar, alterne de volta. O usuário precisa ver a reversão, então anime-a, e mostre um breve estado pendente enquanto o salvamento está em andamento.

3. Inválido, sem punir quem digita

Sacuda o campo quando uma tentativa de envio o encontrar inválido. Nunca sacuda enquanto alguém ainda está digitando. A regra é o gatilho: validação no envio ou no blur ganha a sacudida, validação a cada mudança não.

A sacudida em si deve ser curta, três ou quatro oscilações em cerca de 300ms, e com prefers-reduced-motion ela vira uma mudança de cor na borda e nada mais.

O :user-invalid nativo ajuda aqui. Ele só casa depois que o usuário interagiu, então você pode estilizar o estado inválido em CSS sem uma flag touched no React.

4. Vazio é um estado, não uma ausência

Uma lista vazia é onde os novos usuários caem primeiro, e o padrão é uma área em branco com um cabeçalho acima. Todo estado vazio precisa de três coisas: um ícone ou ilustração para que o espaço pareça intencional, uma frase que diga o que vai aparecer ali e a ação que faz algo aparecer.

Isso é barato de construir e quase sempre pulado, porque quem está construindo a funcionalidade tem dados de teste. Limpe o seu banco de dados de vez em quando.

5. Movimento reduzido não é "sem movimento"

prefers-reduced-motion: reduce está ativo para uma parcela significativa dos usuários, e a implementação preguiçosa é um * { transition: none } global. Isso remove a informação que o movimento carregava. Um diálogo que abre de estalo, sem fade, é mais difícil de entender do que um que faz fade.

A regra que eu uso: tudo que se desloca pela tela vira um fade. Tudo que já é fade continua fade. Tudo que faz loop para. As durações ficam mais curtas, não zero.

Coloque a verificação em um só lugar para que todo componente a leia:

export function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}

E compartilhe as curvas de easing e as durações também. Se cada componente escolhe o próprio cubic-bezier, o app vai parecer que foi feito por seis pessoas, porque foi.

6. Movimento que você pode interromper

Abra um accordion e feche-o no meio da animação de abertura. Em muitas implementações, ele termina de abrir e depois fecha, ou dá um salto. O comportamento certo é reverter de onde quer que esteja.

A Web Animations API facilita isso: guarde uma referência à animação em execução e, na próxima alternância, leia o tempo atual dela e anime a partir da altura computada atual. Transições CSS em height: auto não conseguem fazer isso, e grid-template-rows: 0fr para 1fr consegue, mas é mais difícil de reverter no meio. Escolha a abordagem que permita interromper.

7. Alvos de toque e o zoom do iOS

Duas coisas quebram em celulares que você nunca vai ver em um laptop:

  • Inputs abaixo de 16px dão zoom na página no Safari do iOS. Mantenha o texto dos inputs em 16px em ponteiros grosseiros, mesmo que o seu tema use 15px no resto. Uma única regra @media (pointer: coarse) resolve.
  • Alvos de toque abaixo de 44px erram. Botões podem ter 32px de altura no desktop e crescer em telas de toque pela mesma media query.

8. Da direita para a esquerda

Se algum dia você publicar para usuários de árabe ou hebraico, todo ml-, pl- e left- nos seus componentes é um bug. Use propriedades lógicas: ms-, ps-, start-. O shadcn/ui adicionou suporte a RTL em janeiro de 2026, então os componentes novos que você puxar estão bem, mas os que você copiou em 2024 não.

Onde isso te deixa

Nenhum desses é difícil. Todos juntos são algumas semanas de trabalho em um conjunto de componentes do tamanho do shadcn/ui, e o trabalho fica invisível quando termina, e é por isso que ele é cortado.

Eu construí o HextaUI porque vivia refazendo esse trabalho em todo projeto. É um registry gratuito, sob licença MIT, que você adiciona a um projeto shadcn/ui. Mesma CLI, mesmos nomes de componentes, mesmos tokens de tema, e todos os estados acima já tratados, até o atraso de 150ms do spinner. Se você prefere ser dono do código e escrever os estados por conta própria, os hooks são independentes e você pode instalar só eles.

npx shadcn@latest add https://hextaui.com/r/use-button-feedback.json

FAQ

Devo desabilitar um botão enquanto carrega? Defina aria-busy="true" e ignore os cliques. disabled tira o botão da ordem de tabulação e o esconde dos leitores de tela no meio da ação.

Por quanto tempo um estado de sucesso deve aparecer? Cerca de dois segundos, mais se o ponteiro ainda estiver sobre o controle. Erros, cerca de quatro segundos.

Um skeleton é sempre melhor que um spinner? Um skeleton é melhor quando você conhece a forma do que vem. Um spinner é melhor para ações. Os dois precisam do atraso de 150ms.

Movimento reduzido significa que devo remover toda animação? Não. Substitua deslocamentos por fades, pare os loops e encurte as durações. Mantenha a informação.