12k
All articles

Como Criar uma Barra de Progresso de Leitura

Crie uma barra de progresso de leitura com JavaScript ou animações CSS guiadas por rolagem, com cálculo correto, desempenho e acessibilidade.

OpenReplay Team
OpenReplay Team
Como Criar uma Barra de Progresso de Leitura

Uma barra de progresso de leitura é um indicador fino e fixo (normalmente ancorado ao topo da viewport) que se preenche de 0% a 100% conforme o leitor rola por um artigo longo.

A primeira versão que publiquei chegava a 100% cerca de três telas antes do fim do post, porque estava discretamente medindo os comentários e o rodapé junto com o artigo. Acertar esse detalhe acaba sendo a maior parte do trabalho.

Você pode construir uma de duas formas: um listener de scroll em JavaScript que define a largura de uma barra a partir de um cálculo de porcentagem de rolagem, ou uma animação puramente CSS orientada por rolagem, sem nenhum JavaScript. Este guia apresenta as duas abordagens, a matemática correta de rolagem para barras com escopo de documento e com escopo de artigo, os detalhes de desempenho que mantêm o handler de scroll barato, e o tratamento de acessibilidade e aprimoramento progressivo que você precisa antes de publicar.

Principais Conclusões

  • Para uma barra que abrange todo o documento, o progresso de rolagem é scrollTop / (scrollHeight − clientHeight) × 100; para uma barra que acompanha apenas o artigo, meça o <article>: window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100.
  • Use a fórmula com escopo de artigo quando a página tiver blocos de posts relacionados, comentários ou um rodapé alto, para que a barra atinja 100% no fim do post e não no fim da página.
  • Como o evento scroll dispara em praticamente todos os frames, execute a atualização de largura dentro de requestAnimationFrame e faça cache das leituras de altura, recalculando apenas no resize, para que o handler nunca force layout síncrono.
  • A versão apenas em CSS não precisa de JavaScript: dê a uma barra fixa animation-timeline: scroll(), um @keyframes que anima transform de scaleX(0) para scaleX(1), e animation-duration: 1ms, que é o que o Firefox exige para sequer aplicar a animação, atrás de sua flag ou no Nightly.
  • Animações orientadas por rolagem estão disponíveis no Chrome/Edge 115+, Safari 26+ e Opera, mas ainda não são Baseline, porque o Firefox estável ainda as mantém atrás de uma flag. Trate a barra apenas em CSS como aprimoramento progressivo.

O que é uma barra de progresso de leitura e quando usá-la?

Uma barra de progresso de leitura codifica visualmente “quanto falta deste post” como uma barra que cresce ao longo do topo da tela. Ela combina com conteúdo longo (tutoriais aprofundados, ensaios, documentação) em que o leitor se beneficia de uma noção de posição que as barras de rolagem finas modernas não oferecem mais. Em páginas curtas, uma landing page, ou qualquer coisa que caiba em uma ou duas viewports, ela adiciona ruído visual sem informar ninguém; pule-a nesses casos.

Duas decisões de design definem o restante da construção: qual região a barra mede (o documento inteiro ou apenas o corpo do artigo), e se você a implementa em JavaScript ou em CSS.

Como calcular o progresso de leitura?

Acerte a matemática e todo o resto vem em seguida. Existem duas fórmulas corretas, dependendo do que você quer que a barra represente.

Rolagem do documento inteiro. Para uma barra que se preenche conforme a página inteira rola, o progresso é a distância rolada dividida pela distância máxima rolável:

progress = scrollTop / (scrollHeight − clientHeight) × 100

O denominador subtrai a altura visível porque você nunca consegue rolar a última viewport de conteúdo para fora da vista: o fim da página é alcançado enquanto uma tela inteira ainda está visível. No root scroller, scrollHeight é a altura total do conteúdo e clientHeight é a altura visível.

Rolagem com escopo de artigo. Uma barra de documento inteiro conta seu rodapé, comentários e blocos de posts relacionados, então ela chega a 100% no fim da página, não no fim do post. Para corrigir isso, meça o elemento <article> em vez disso:

distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100

Aqui, distance é a trajetória de rolagem desde a primeira renderização até o momento em que a borda inferior do artigo entra na vista. Use a fórmula com escopo de artigo quando sua página tiver algo substancial abaixo do post; use a fórmula de documento quando o conteúdo rolável for a página inteira. Observe que offsetTop é medido em relação ao ancestral posicionado mais próximo, então mantenha o artigo no fluxo normal do documento para que o número signifique “distância a partir do topo da página”.

Implementação em JavaScript

A abordagem em JavaScript funciona em todos os navegadores e é a única forma de obter progresso preciso com escopo de artigo. Você precisa de um elemento de barra fixo, um pouco de CSS e um handler de scroll.

<div id="progress-bar" aria-hidden="true"></div>
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 4px;
  background: linear-gradient(to right, #7b2ff7, #f107a3);
  z-index: 9999;
}
const bar = document.getElementById("progress-bar");
const article = document.querySelector("article");
let distance = 0;
let ticking = false;

function measure() {
  distance = (article.clientHeight + article.offsetTop) - window.innerHeight;
}

function update() {
  const progress = Math.min((window.scrollY / distance) * 100, 100);
  bar.style.width = `${progress}%`;
  ticking = false;
}

function onScroll() {
  if (!ticking) {
    requestAnimationFrame(update);
    ticking = true;
  }
}

window.addEventListener("load", () => { measure(); update(); });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);

As medições rodam no handler de load para que imagens e fontes já tenham se estabilizado e clientHeight seja preciso. Troque o distance com escopo de artigo pela fórmula de documento se quiser uma barra de página inteira.

Mantendo o handler de scroll rápido

O evento scroll pode disparar em praticamente todos os frames de animação, então um handler ingênuo que lê layout e escreve estilos a cada evento é uma fonte garantida de jank. Duas regras o mantêm barato.

Primeiro, agrupe a escrita visual dentro de requestAnimationFrame usando a flag ticking mostrada acima, para que você atualize a barra no máximo uma vez por frame, independentemente da frequência com que o scroll dispara. Segundo, faça cache das leituras de altura. Ler clientHeight/offsetTop a cada evento de scroll força o navegador a esvaziar o layout pendente, e esses reflows repetidos são o que o layout thrashing é na prática, então calcule distance uma vez e recalcule apenas no resize. Um modo de falha comum em produção é exatamente esse: um listener sem throttling que lê geometria e escreve width a cada evento, e session replays de páginas com muita rolagem frequentemente revelam as quedas de frame resultantes. Registrar o listener como { passive: true } também informa ao navegador que você não chamará preventDefault, mantendo a rolagem suave.

A barra de progresso de leitura apenas em CSS

Você pode construir a barra com zero JavaScript usando animações CSS orientadas por rolagem. Vincule uma animação a uma scroll timeline em vez de ao tempo decorrido, e o navegador controlará a escala horizontal da barra a partir da posição de rolagem. Como a animação atua sobre um transform em vez de uma propriedade de layout, ela pode rodar no compositor em vez de passar por um listener de scroll na main thread.

<div id="reading-progress" aria-hidden="true"></div>
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    #reading-progress {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 4px;
      z-index: 9999;
      background: #7b2ff7;
      transform: scaleX(0);
      transform-origin: left;
      animation-name: grow-progress;
      animation-timeline: scroll();
      animation-duration: 1ms; /* required so the animation runs in Firefox */
      animation-timing-function: linear;
    }
    @keyframes grow-progress {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }
    @media (prefers-color-scheme: dark) {
      #reading-progress { background: #fc0; }
    }
  }
}

A barra é renderizada com largura total e comprimida a nada com transform: scaleX(0), e então escalonada de volta conforme você rola. transform-origin: left é o que faz com que ela cresça a partir da borda esquerda em vez do centro. Animar width teria aparência idêntica, mas forçaria layout a cada frame, o que traz a animação de volta para a main thread.

Mais dois detalhes importam. Chamado sem argumentos, scroll() escolhe o ancestral rolável mais próximo e segue seu eixo de bloco, o que para a maioria dos layouts de artigo em coluna única significa o root scroller; passe root se quiser nomeá-lo explicitamente. E o Firefox se recusa a aplicar a animação a menos que animation-duration seja diferente de zero, então o costumeiro 1ms é o que faz com que ela funcione ali, e o mesmo valor mantém a barra oculta em navegadores sem suporte.

Esse último ponto é o trade-off. animation-timeline não é Baseline. Está disponível no Chrome e Edge 115+, Safari 26+ e Opera, enquanto o Firefox estável ainda o mantém atrás da flag layout.css.scroll-driven-animations.enabled e o habilita por padrão apenas no Nightly. O guard @supports acima é o contrato de aprimoramento progressivo: navegadores compatíveis recebem a barra em CSS, os demais não recebem nada renderizado, então combine-a com a versão em JavaScript como fallback se precisar de cobertura universal. Observe também que a barra apenas em CSS mede o container de rolagem inteiro, portanto ela conta rodapé e conteúdo de comentários exatamente como a fórmula JS com escopo de documento.

JavaScript vs. apenas CSS: qual usar

Barra em JavaScriptBarra apenas em CSS
Suporte de navegadoresEm todosChromium 115+, Safari 26+; Firefox atrás de uma flag
Precisão com escopo de artigoSimNão, ela conta a página inteira
Custo na main threadListener de scrollNenhum por frame, o transform roda no compositor
Requer JavaScriptSimNão

Use JavaScript quando precisar que a barra pare no fim do post ou quando precisar suportar todos os navegadores; use a barra apenas em CSS quando quiser um indicador de página inteira com código mínimo e puder tratá-lo como um aprimoramento.

Acessibilidade e refinamento

Uma barra de progresso é um elemento decorativo de interface, então marque-a com aria-hidden="true" para mantê-la fora da árvore de acessibilidade, longe da saída de leitores de tela e da ordem de foco. Se você realmente quiser que o valor seja anunciado, use role="progressbar" com um aria-valuenow dinâmico, embora, para a maioria dos indicadores de leitura, ocultá-la seja o correto. Envolva a animação CSS em @media (prefers-reduced-motion: no-preference) para que usuários que optaram por reduzir movimento não recebam um elemento animado, e escolha uma cor de barra com contraste suficiente em relação ao seu cabeçalho para que ela permaneça visível tanto em temas claros quanto escuros.

Ambas as abordagens produzem o mesmo resultado visível; a versão em JavaScript garante precisão com escopo de artigo e suporte universal, enquanto a versão apenas em CSS oferece uma implementação menor que mantém o trabalho por frame fora da main thread. Comece pela que corresponder aos seus navegadores-alvo, mantenha a matemática de rolagem e o detalhe do animation-duration: 1ms exatamente como mostrado, e combine as duas com @supports se quiser o melhor dos dois mundos.

Perguntas Frequentes

Por que minha barra de progresso chega a 100 por cento antes de eu terminar de ler o artigo?

A barra está medindo o documento inteiro em vez do artigo, então ela conta seu rodapé, comentários e blocos de posts relacionados na distância rolável. Mude para a fórmula com escopo de artigo: calcule distance como (article.clientHeight + article.offsetTop) menos window.innerHeight, e então divida window.scrollY por essa distância. A barra passa então a atingir 100 por cento no fim do post, e não no fim da página.

Por que a barra de progresso apenas em CSS funciona no Chrome mas não no Firefox?

O Firefox mantém as animações orientadas por rolagem atrás da flag layout.css.scroll-driven-animations.enabled em suas versões estáveis, com a preferência ativada por padrão apenas no Nightly, então um Firefox sem a flag não renderiza nada. Separadamente, o Firefox não aplica a animação de forma alguma a menos que animation-duration seja diferente de zero, e é por isso que 1ms é o valor que todo mundo usa. Combine a barra em CSS com um guard at-supports e um fallback em JavaScript para cobertura total.

A barra apenas em CSS funciona sem um listener de evento de scroll?

Sim. As animações CSS orientadas por rolagem vinculam a animação a uma scroll timeline em vez do tempo decorrido, então o navegador controla o transform da barra diretamente a partir da posição de rolagem, sem listener de scroll em JavaScript e sem IntersectionObserver na main thread. Animar um transform em vez de width é o que a mantém amigável ao compositor: no Chromium e no Safari 26.4 ou posterior a animação roda na thread do compositor, enquanto versões anteriores do Safari 26.x executavam animações orientadas por rolagem na main thread. Animar width ou height forçaria layout a cada frame e devolveria o trabalho à main thread em todos os navegadores.

Uma barra de progresso de leitura deve ser exposta a leitores de tela?

Não, para a maioria dos indicadores de leitura. Uma barra de progresso é um elemento decorativo de interface, então marque-a com aria-hidden='true' para mantê-la fora da árvore de acessibilidade, longe da saída de leitores de tela e fora da ordem de foco. Somente se você realmente precisar que o valor seja anunciado é que deveria usar role='progressbar' com um atributo aria-valuenow dinâmico, mas ocultar um indicador de leitura puramente visual é o padrão correto.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.