Uma faixa com números gigantes lado a lado, separados por filete vertical, cada um com um rótulo curto em caixa alta embaixo. Quando a faixa entra na tela, os números sobem de zero até o alvo em 1,8 segundos e param — uma vez só, nunca de novo. No mobile a régua vira pilha e o filete troca de eixo.
A contagem é IntersectionObserver + requestAnimationFrame, ~30 linhas. Nenhuma lib.
Quando usar
- Dobra de prova social em que o número é o argumento: anos de operação, projetos entregues, percentual de entrega no prazo, patrimônio sob gestão. Se o texto do rótulo é mais interessante que o número, o componente é o errado.
- Entre duas dobras de texto denso, como respiro duro. Três números de 92px param o olho sem pedir leitura.
- Depois de um case ou de um manifesto, para aterrissar em fato o que a dobra anterior afirmou em prosa.
Quando não usar
- Quando os números não são números. "Global", "CVM", "SP · POA" são credenciais, não quantidades — não contam, não têm alvo, e metade da régua contando enquanto a outra metade fica parada lê como bug. Para isso existe a régua do
hero/full-bleed-media; veja a comparação abaixo. - Acima da dobra. A contagem depende de entrar na tela para disparar; num hero ela dispara no carregamento, junto do reveal da headline, e as duas coisas brigam.
- Com número que precisa de separador de milhar (
12.480). O componente não agrupa milhar — veja os gotchas. - Quando o número é frágil e vai envelhecer em três meses. Um "240+" que fica "240+" por dois anos é pior que nenhum número.
- Mais de quatro itens. Cinco números de 52px não são mais uma régua; são uma lista mal formatada.
Padrão editorial
| Slot | Regra |
|---|---|
Olho (.eyebrow) |
1 a 3 palavras. "Em números", "Resultados", "Desde 2014". Opcional. |
Título (.sec-title) |
1 linha, uma palavra em <em>. Nomeia a medida, não repete os números: "O trabalho, medido". |
Apoio (.sec-lead) |
1 parágrafo, 15 a 30 palavras. Diz de onde vêm os números e de quando são. Opcional, mas é aqui que a régua ganha credibilidade. |
Número (.stat-num) |
Até 5 glifos contando afixo: 12+, 240+, 98,5%, R$ 40mi. Acima disso transborda a célula de propósito. |
Rótulo (.stat-label) |
2 a 4 palavras, no máximo duas linhas. Substantivo, sem verbo: "Projetos entregues", não "Já entregamos projetos". Minúsculas no HTML — a caixa alta é do CSS. |
Pílula do rodapé (.pill) |
A data da apuração. "Apurado em 01/2026". Número sem data é promessa. |
Botão (.btn) |
1, no máximo. Leva ao lugar onde o número se comprova (cases, relatório). Opcional. |
Três é o padrão. Quatro cabe baixando --stat-size. Dois funciona, mas com dois itens o filete central vira eixo de simetria e a faixa fica parecendo comparação — cuidado se os números não forem comparáveis.
Todo número precisa ser verificável. A régua é o lugar da página onde uma afirmação vira dado; se ninguém consegue conferir, escreva prosa e não coloque em 92px.
Como usar
<link rel="stylesheet" href="core/tokens.css">
<link rel="stylesheet" href="core/temas/<projeto>.css">
<link rel="stylesheet" href="core/base.css">
<link rel="stylesheet" href="core/motion.css">
<link rel="stylesheet" href="componentes/prova/stats-contador-regua/estilo.css">
...
<script src="core/motion.js"></script>
<script src="componentes/prova/stats-contador-regua/script.js"></script>
Cole o markup.html, troque os números e os rótulos. Depende do core e de mais nada.
Os quatro atributos
Toda a parametrização mora no markup. O script não conhece nenhum número da sua página.
| Atributo | Onde | O quê |
|---|---|---|
data-stats |
na faixa (.stat-band) |
a raiz. O script aceita várias na mesma página |
data-count |
em cada .stat-num |
o alvo. Sempre com ponto: 98.5, nunca 98,5 |
data-suffix |
opcional | vem depois: +, %, mi, /5 |
data-prefix |
opcional | vem antes: R$, +, ~ |
data-dec |
opcional | separador decimal na tela. Padrão ,; use . em projeto em inglês |
<p class="stat-num" data-count="98.5" data-suffix="%">98,5%</p>
<p class="stat-num" data-prefix="R$ " data-count="40" data-suffix=" mi">R$ 40 mi</p>
O texto escrito no HTML
O .stat-num traz o valor final escrito, não o zero. Isso é decisão, não descuido:
- sem JS, é ele que fica na tela. Uma régua que carrega escrita
0%afirma zero para quem desligou o script, para o modo leitura e para qualquer robô que não execute JS; - com JS, o script reescreve o campo na hora em que executa — zerando (
0%,+0) para a contagem partir do zero, ou já no valor final sob movimento reduzido.
Ou seja: o texto do markup é fallback; a fonte da verdade é o data-count. Os dois não conseguem divergir na tela, porque o script sempre reescreve.
Variáveis locais
No .stat-band:
| Variável | Padrão | O quê |
|---|---|---|
--stat-cols |
3 |
quantidade de colunas no desktop |
--stat-gap |
clamp(16px,2.5vw,44px) |
respiro interno de cada célula |
--stat-size |
clamp(52px,7.5vw,92px) |
corpo do número |
Variações
| Variação | Como |
|---|---|
| 4 números | --stat-cols:4 e --stat-size:clamp(38px,5.4vw,64px) |
| Números na cor de marca | .stat-band--accent |
| Régua em card de vidro | class="glass stat-band stat-band--card" — compõe a primitiva do core |
| Faixa nua | apague .sec-head e .stat-foot. Foi a forma de origem: uma faixa entre duas dobras, sem título |
| Filete horizontal fechando a régua | border-block:1px solid var(--rule);padding-block:clamp(26px,4vh,40px) no tema. Experimente sem antes: os verticais já dão a estrutura, e os quatro juntos leem tabela |
| Sem contagem | não carregue o script.js. O markup já está pronto e correto |
A régua daqui e a régua do hero
O hero/full-bleed-media já embute uma régua de quatro números (.hero-stats / .hero-k / .hero-v). São coisas diferentes e devem continuar separadas.
hero/full-bleed-media |
prova/stats-contador-regua |
|
|---|---|---|
| Pergunta que responde | por que confiar | quanto |
| Conteúdo dos slots | chaves, nem sempre numéricas: 100%, SP · POA, Global, CVM |
quantidades, sempre numéricas |
| Corpo | 19px, cor --accent |
52–92px, cor --fg |
| Separação | um filete horizontal em cima, nada entre os itens | filete vertical entre os itens |
| Itens | 4 fixos | 3 (2 a 4 por --stat-cols) |
| Entrada | cascata do hero, delay .95s | [data-rv] do core, e a contagem em cima |
| Movimento | nenhum depois de entrar | conta 1,8s |
| Onde vive | dentro do hero, acima da dobra | seção própria, abaixo da dobra |
O hero deveria passar a compor este componente? Não, e por quatro motivos concretos:
- Metade dos slots do hero não é número.
CVMeSP · POAnão têm alvo. Numa régua onde dois itens contam e dois ficam parados, o resultado lê como falha, não como decisão. - A contagem não cabe acima da dobra. A régua do hero entra em
.95s, no fim de uma cascata que já tem headline em duas linhas, apoio e CTA. Somar 1,8s de contagem empurra o "a página assentou" para depois dos 2,7s e põe movimento competindo com a headline no momento em que ela é a única coisa que importa. - 19px e 92px não são o mesmo objeto tipográfico. Fundir os dois exigiria transformar o corpo em variável e o componente perderia identidade: um "número gigante" de 19px não é este componente, é outro.
- A régua do hero são seis linhas de CSS. Trocá-las por um componente com
--stat-cols, filetes, empilhamento e observer é mais código para fazer menos.
O que efetivamente compõe é o script, não o CSS. O script.js daqui procura [data-count] dentro de [data-stats] e troca textContent — ele não conhece uma única classe stat-. Um projeto que queira o hero contando adiciona data-stats no .hero-stats e data-count nos .hero-k numéricos, carrega este script, e pronto: zero CSS, zero alteração no hero. Essa costura existe de propósito e está documentada aqui para não ser redescoberta. A recomendação continua sendo não usar (motivo 2), mas a porta está aberta e é de duas linhas.
Se um dia um terceiro componente pedir a mesma dupla "chave grande + rótulo pequeno separados por filete", aí sim vale promover ao core como primitiva, conforme o §6 do contrato. Com dois, ainda não.
Gotchas
-
font-variant-numeric:tabular-numsnão é refinamento, é o que segura a contagem de pé. Na maioria das fontes proporcionais cada algarismo tem avanço próprio — o1é estreito, o8é largo. Sem ele o número treme lateralmente a cada frame, porque a largura muda a cada troca de dígito. Com ele, todo algarismo ocupa o mesmo avanço e a largura só cresce quando entra um dígito novo. Se a--displaydo tema não tiver algarismos tabulares (fonte display comprada costuma não ter), ofont-variant-numericnão tem o que ativar e a tremida volta. Sinal: o%ou o+do fim quica durante a contagem. Saídas, em ordem:font-feature-settings:"tnum" 1(ativa a feature em base antiga, se ela existir na fonte); trocar o.stat-numparavar(--sans); ou reservar a largura commin-widthemchno.stat-num. Não desista do componente: desista da fonte para este uso. -
data-countvai sempre com ponto. É número de máquina, lido porparseFloat. Quem escreverdata-count="98,5"recebe98— oparseFloatpara na vírgula, sem erro nenhum no console. O separador que aparece na tela é outra coisa e vem dedata-dec, que nasce vírgula. -
As casas decimais saem do próprio
data-count.98.5conta com uma casa,12conta inteiro,4.90conta com duas (e mostra4,90). Se o número aparecer com casa a mais ou a menos, o problema é o zero sobrando no atributo, não o script. O original arredondava tudo comMath.rounde engolia qualquer decimal:4.9chegava na tela como5. -
Não existe separador de milhar.
12480conta e mostra12480, sem ponto. É deliberado: milhar agrupado em 92px vira parede de dígito e o separador brasileiro (.) colide com o decimal. Escreva12comdata-suffix=" mil", ou12.5comdata-suffix=" mil". Se um projeto precisar mesmo de agrupamento, isso é mudança no script — e a conversa começa porIntl.NumberFormat, não por regex. -
O número não quebra linha (
white-space:nowrap). Se quebrasse no meio da contagem — ao passar de99para1240— o rótulo saltaria para baixo junto. Número comprido demais transborda a célula. O transbordo é intencional e serve de alarme: encurte o número. -
O script tem de ficar no fim do
<body>, semdefernemasync. Ele zera os números na execução, e a aposta é executar antes da primeira pintura. Comdefer, o valor final pisca e some. O risco é pequeno mesmo em página longa, porque ele e a visibilidade são inversamente proporcionais: se a régua está no meio de uma página longa (onde o navegador pode pintar antes de chegar no script), ela está fora da tela e ninguém vê o pisca; se ela está alta o bastante para ser vista, a página é curta o bastante para o script rodar antes. -
Sob
prefers-reduced-motionnão há contagem, e isso é o comportamento correto — não uma degradação. O script reescreve o valor final e sai. Não observa, não anima, não agenda frame. Se você tirar essa guarda, tire junto a promessa da ficha. -
Um leitor de tela que chegue no meio da contagem lê o valor parcial. Não há
aria-live(que seria pior: anunciaria dezenas de valores). Na prática a contagem dura 1,8s e só começa quando a faixa entra na tela, então a leitura quase sempre acontece depois. Se o projeto precisar de garantia, ponhaaria-hidden="true"no.stat-nume o valor final num rótulo próprio na célula — mas confira antes se aquele público não está, na maioria, com movimento reduzido ligado, caso em que o problema não existe. -
--stat-colsnão controla o mobile de propósito. O empilhamento égrid-template-columns:1frescrito direto na media query, não a variável. Assim quem escrever--stat-cols:4numstyleinline (que ganha de media query) não consegue quebrar o mobile sem querer. -
Um
IntersectionObserverpor faixa. Fecha o disparo em closure e evita mapa de elementos ou propriedade pendurada no DOM. Com uma ou duas faixas por página é o custo certo; se um dia forem muitas, troque por um observer único com mapa. -
A contagem acontece uma vez só, e não volta.
unobserveno primeiroisIntersecting. Rolar de volta não reinicia — de propósito: número que reconta a cada scroll vira enfeite e o leitor para de acreditar nele. -
<dl>foi considerado e recusado. Número e rótulo formam par termo/descrição, e a marcação semântica seria uma lista de descrição. Mas<dt>tem de vir antes de<dd>, e aqui o número vem primeiro na tela — sairia uma inversão por CSS que estraga a ordem de leitura justamente para quem a semântica serviria. Ficou<div>+ dois<p>.
Histórico
| Projeto | Onde | Data | Diferenças |
|---|---|---|---|
| Gabriel Rocha Studio | gabriel-rocha-studio-2.html, section.stats |
2026-08 | Origem. Site autoral, autocontido, com GSAP e ScrollTrigger carregados para a página inteira. |
O que mudou na travessia:
| No original | Aqui | Por quê |
|---|---|---|
gsap.to({val:0}, …) como tweener e ScrollTrigger para a hora |
IntersectionObserver + requestAnimationFrame, easeOutCubic no lugar de power3.out |
Contrato §1: GSAP só entra em timeline encadeada ou ScrollTrigger com scrub. Isto não é nenhum dos dois — é um número subindo |
parseFloat + Math.round |
casas derivadas do próprio data-count + toFixed |
4.9 chegava na tela como 5. Agora 98.5 conta com uma casa e mostra 98,5 |
0 escrito no HTML |
valor final escrito no HTML, zerado pelo script antes da primeira pintura | Sem JS, a régua inteira afirmava zero |
.stat-item{border-right:0!important} no mobile |
mesmo seletor :not(:last-child), mesma especificidade, mais adiante na folha |
Contrato §6: zero !important. Especificidade igual + ordem resolve |
var(--mono) no rótulo |
var(--sans) |
A biblioteca não tem token de fonte mono. Criar um é decisão de arquitetura (§3) e ficou em aberto — veja abaixo |
Um observer global disparando por .stats |
um observer por [data-stats], partindo da raiz |
Contrato §7.6: o componente pode repetir na página |
Cores cruas var(--line), var(--muted) do site |
--rule, --fg-muted, --fg |
Tokens da biblioteca, para rodar em qualquer tema |
| Sem tratamento de movimento reduzido | valor final escrito, sem contagem, sem observer | Exigência do contrato |
| Faixa nua entre duas seções | ganhou .sec-head e .stat-foot opcionais |
A forma nua continua disponível apagando os dois blocos |
| Números afirmando fato sobre pessoa real | conteúdo genérico de exemplo | Regra da biblioteca: markup de componente é dimensionamento, não conteúdo |
Em aberto — token de fonte mono
O site de origem usa --mono (DM Mono) em todo rótulo pequeno: olho, legenda de card, número de acordeão, ticker, rodapé. A biblioteca não tem esse token e este componente resolveu com var(--sans), que é o que .eyebrow e .marquee-label já fazem. Funciona e está consistente.
Mas se mais componentes desse site entrarem, a decisão volta: ou todos convertem mono em --sans (e a voz de rótulo do projeto se perde na travessia), ou a biblioteca ganha um --mono em core/tokens.css com fallback monospace. É decisão de arquitetura, fica registrada aqui e não foi tomada.
Em aberto — primitiva de rótulo
11px / caixa alta / tracking largo / --fg-muted já aparece em .eyebrow (com traço), .marquee-label, .fold-cap, .pill e agora .stat-label. Pelo §6 do contrato isso passou de três e é candidato a primitiva — um .label sem traço, do qual .eyebrow seria a variação com traço. Não promovi: mexer no core está fora do escopo desta extração, e a mudança toca cinco componentes. Fica registrado para quem for fazer a próxima varredura.
Status experimental: rodou uma vez, no site de origem, e nesta forma normalizada nunca. Espere ajuste no primeiro uso real — provavelmente no corpo do número com quatro colunas e na fonte do rótulo.