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
- 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. - Marque o esqueleto com
aria-hidden="true", porque ele não tem informação, e a lista comaria-busy="true"enquanto carrega. - Escreva o estado na região de status: "Carregando pedidos...".
- Atualize o texto se demorar: depois de alguns segundos, "Ainda carregando. A conexão parece lenta."
- Dê um prazo à requisição com
AbortSignal.timeout. - 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".
- No sucesso, anuncie o resultado ("12 pedidos carregados") e trate a lista vazia como estado próprio.
- Respeite
prefers-reduced-motiondesligando 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?
Referência: https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Guides/Live_regions