Programação Por Equipe DevBras

Capa do artigo: Tela de carregamento que não fica cinza para sempre: esqueleto, texto e erro

Tela de carregamento que não fica cinza para sempre: esqueleto, texto e erro

Combine o esqueleto visual com uma região de status em texto, prazo para desistir e troca por erro com "Tentar de novo", para quem vê e para quem ouve a tela.

O painel de pedidos abre com três retângulos cinza pulsando. A API travou num deploy e nunca respondeu. Dois minutos depois, os retângulos continuam pulsando. A pessoa atualiza a página, espera de novo, e liga para o suporte dizendo que o sistema caiu. Quem usa leitor de tela não ouviu nem o "carregando": os blocos não têm texto. A resposta curta: o esqueleto é enfeite, a informação precisa estar em palavras. Deixe uma região de status com o texto "Carregando pedidos", marque a área como ocupada, dê à requisição um prazo para desistir e, se falhar, troque o esqueleto por uma mensagem que diz o que aconteceu e um botão "Tentar de novo".

Um esqueleto sem prazo e sem estado de erro é uma promessa sem data. Ele parece moderno nos primeiros dois segundos e parece defeito a partir do décimo.

Por que o esqueleto sozinho falha

O esqueleto comunica uma coisa só, visualmente: "algo vai aparecer aqui com mais ou menos este formato". Ele não diz quanto falta, não diz se algo deu errado e não existe para quem não vê a tela. Para o leitor de tela, um bloco cinza sem texto é nada, e a pessoa ouve uma página vazia.

O segundo problema é técnico. A maioria dos códigos de carregamento tem dois estados: carregando e carregado. Quando a requisição falha ou nunca volta, nenhum dos dois termina, e a tela fica presa no primeiro. Um fetch sem prazo pode esperar muito tempo por uma conexão que não responde; o que acontece nesse caso depende do navegador e da rede, e você não controla. Sobre prazos em chamadas externas em geral, veja toda chamada externa precisa de um prazo.

O terceiro é a animação. Um brilho que passa pelo bloco o tempo todo incomoda quem tem sensibilidade a movimento, e o sistema operacional permite que a pessoa peça menos animação. O CSS consegue respeitar esse pedido.

Como montar o carregamento com quatro estados

  1. Deixe a região de status no HTML desde o início, vazia, com role="status". Leitores de tela anunciam mudanças em regiões que já existiam; uma região criada junto com o texto pode não ser anunciada.
  2. Marque o esqueleto com aria-hidden="true", porque ele não tem informação, e a lista com aria-busy="true" enquanto carrega.
  3. Escreva o estado na região de status: "Carregando pedidos...".
  4. Atualize o texto se demorar: depois de alguns segundos, "Ainda carregando. A conexão parece lenta."
  5. Dê um prazo à requisição com AbortSignal.timeout.
  6. Em caso de falha, troque o esqueleto pela mensagem e pela ação: o que houve, em linguagem simples, e o botão "Tentar de novo".
  7. No sucesso, anuncie o resultado ("12 pedidos carregados") e trate a lista vazia como estado próprio.
  8. Respeite prefers-reduced-motion desligando a animação.

O HTML

<section aria-labelledby="titulo-pedidos">
  <h2 id="titulo-pedidos" tabindex="-1">Pedidos recentes</h2>
  <p id="estado-pedidos" role="status"></p>
  <div id="lista-pedidos"></div>
</section>

O JavaScript

const titulo = document.getElementById('titulo-pedidos');
const estado = document.getElementById('estado-pedidos');
const lista = document.getElementById('lista-pedidos');

function mostrarEsqueleto() {
  const blocos = [];
  for (let i = 0; i < 3; i++) {
    const bloco = document.createElement('div');
    bloco.className = 'esqueleto';
    bloco.setAttribute('aria-hidden', 'true');
    blocos.push(bloco);
  }
  lista.replaceChildren(...blocos);
}

function mostrarErro() {
  estado.textContent = 'Não conseguimos carregar os pedidos. Verifique a conexão e tente de novo.';
  const botao = document.createElement('button');
  botao.type = 'button';
  botao.textContent = 'Tentar de novo';
  botao.addEventListener('click', () => {
    titulo.focus();
    carregarPedidos();
  });
  lista.replaceChildren(botao);
}

async function carregarPedidos() {
  mostrarEsqueleto();
  lista.setAttribute('aria-busy', 'true');
  estado.textContent = 'Carregando pedidos...';
  const lento = setTimeout(() => {
    estado.textContent = 'Ainda carregando. A conexão parece lenta.';
  }, 8000);

  try {
    const resposta = await fetch('/api/pedidos', { signal: AbortSignal.timeout(20000) });
    if (!resposta.ok) throw new Error('HTTP ' + resposta.status);
    const pedidos = await resposta.json();
    mostrarPedidos(pedidos);
    estado.textContent = pedidos.length
      ? 'Pedidos carregados: ' + pedidos.length + '.'
      : 'Nenhum pedido nos últimos 30 dias.';
  } catch (erro) {
    mostrarErro();
  } finally {
    clearTimeout(lento);
    lista.setAttribute('aria-busy', 'false');
  }
}

carregarPedidos();

O CSS da animação

.esqueleto {
  height: 3rem;
  margin-block: 0.5rem;
  border-radius: 0.25rem;
  background: #e5e7eb;
  animation: pulsar 1.5s ease-in-out infinite;
}

@keyframes pulsar {
  50% { opacity: 0.5; }
}

@media (prefers-reduced-motion: reduce) {
  .esqueleto { animation: none; }
}

Repare nos quatro finais possíveis. Rápido: o esqueleto pisca, a lista aparece e o leitor anuncia quantos pedidos vieram. Lento: aos 8 segundos o texto muda e a pessoa sabe que o sistema continua tentando. Falha ou prazo estourado: aos 20 segundos, no máximo, a mensagem de erro aparece com o botão. Vazio: a lista diz que não há pedidos no período, em vez de ficar em branco, o que está detalhado em lista vazia é uma instrução.

O suporte a aria-busy varia entre leitores de tela, por isso ele é complemento. O que garante o anúncio é o texto na região de status. Se o carregamento costuma levar menos de um segundo, você pode atrasar o texto "Carregando" em meio segundo para não anunciar algo que já terminou; o importante é que estados longos e falhas sempre tenham texto.

O foco no "Tentar de novo"

Há uma armadilha no botão de tentar de novo. Quando a pessoa o aciona pelo teclado, o mostrarEsqueleto substitui o conteúdo da lista e remove o próprio botão que tinha o foco. Sem tratamento, o foco cai no body e o próximo Tab recomeça do topo da página. Por isso o clique move o foco para o título da seção antes de recarregar. O título tem tabindex="-1", que permite receber foco por script sem entrar na ordem normal do Tab, e a pessoa continua exatamente onde estava.

Os números do prazo

Escolha os prazos olhando o tempo de resposta real da rota. Se o percentil 99 da API de pedidos é 1,2 segundo, 8 segundos já é lentidão anormal, e 20 segundos é falha para efeitos práticos. Para uma exportação que leva um minuto, o esqueleto não é o padrão certo: use uma barra de progresso com texto, como em barra de progresso que diz quanto falta.

Erros comuns

  • Esqueleto como único sinal de carregamento. Quem usa leitor de tela não recebe nenhuma informação.
  • Requisição sem prazo. A tela pode ficar em "carregando" indefinidamente.
  • Nenhum estado de erro. A falha aparece como esqueleto eterno e vira ligação para o suporte.
  • Região de status criada junto com o texto. O anúncio pode não acontecer.
  • Erro sem ação. "Algo deu errado" sem botão obriga a recarregar a página inteira e perder filtros.
  • Animação que ignora prefers-reduced-motion. Desconforto para quem pediu menos movimento ao sistema.

Checklist

  • A região role="status" existe no HTML antes do carregamento.
  • O esqueleto tem aria-hidden="true".
  • O texto "Carregando" aparece em palavras, não só no visual.
  • Existe mensagem para carregamento lento.
  • A requisição tem prazo para desistir.
  • A falha troca o esqueleto por mensagem e botão de tentar de novo.
  • A lista vazia tem frase própria.
  • A animação para com prefers-reduced-motion.

Abra o seu painel, ative nas ferramentas do desenvolvedor a opção de deixar a rede offline e recarregue: depois de 30 segundos, a tela explica o que aconteceu ou continua cinza?

Avalie este artigo

Referência: https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Guides/Live_regions