Duas colunas e um fio no meio. À esquerda, quatro blocos de texto altos que passam rolando; à direita, um painel de foto que fica preso no topo da tela e troca de imagem em fusão com blur toda vez que o bloco correspondente chega à faixa de leitura. O bloco ativo acende em tinta cheia e os outros ficam em tinta de apoio, e entre as colunas um fio de 2px se preenche continuamente, com um ponto por passo que acende na vez dele. O olho faz uma coisa só: lê para baixo. A foto muda sozinha, no canto do campo de visão, e a pessoa não repara que está operando um carrossel.
Resolve a dobra em que uma coisa acontece em etapas e cada etapa tem uma imagem própria — fases da vida, etapas de um método, camadas de um serviço. A lista comum obrigaria a escolher: ou uma foto para o conjunto (e três das quatro etapas ficam sem ilustração), ou uma foto por item (e a dobra vira uma grade de cards, em que a foto encolhe até não dizer mais nada). Aqui a foto ocupa metade da tela em todas as etapas, uma de cada vez, sem custar uma tela de altura por item.
Quando usar
- Sequência de 3 a 5 blocos que se lê em ordem e em que cada bloco tem uma imagem própria, boa, em tamanho grande.
- Texto de fôlego: 60 a 90 palavras por bloco. É o formato que aguenta parágrafo — a leitura tem uma coluna inteira e nada disputa com ela.
- Página que já ganhou a atenção. A dobra custa cerca de três telas e meia de rolagem no desktop (medido: 3 039px de seção a 1440×900).
- Quando a ordem importa mas não há "passo 1 → passo 2" a executar: fases, momentos, camadas. É uma narrativa, não um checklist.
Quando não usar
- Etapas de um método ou processo que a pessoa vai executar em ordem:
listas/baralho-stickyfaz isso melhor, com uma foto só e cards que se empilham — e diz "sequência" com mais força do que esta dobra. - Quando a página já tem outra dobra presa ao scroll. Duas competem, e a segunda perde: o visitante já aprendeu que rolar mexe em alguma coisa e para de olhar.
- Uma imagem só para o conjunto, com texto corrido:
conteudo/apresentacao-foto-alternada(retrato que se reveza) ouconteudo/imagem-lista-numerada(foto fixa com lista ao lado). - Itens que a pessoa precisa comparar em vez de ler em sequência:
listas/leque-paineisoumidia/galeria-scroll-horizontaldeixam tudo alcançável sem rolar três telas. - Perguntas e respostas, ou qualquer coisa em que se pula direto ao item de interesse:
listas/acordeao-faq. - Mais de cinco blocos. A coluna passa de quatro telas e o formato deixa de ser generoso e vira longo.
Padrão editorial
| Slot | Regra |
|---|---|
| Olho | 1 palavra, no máximo 2. É o nome da série ("Fases"), não uma frase. |
| Título da seção | 1 linha, uma palavra em <em>. Alinhado à esquerda, como a coluna que ele abre. |
.sec-lead |
Opcional. 1 frase, até 20 palavras. Serve para dizer o que os quatro blocos têm em comum. |
.sync-num |
Dois dígitos, 01 a 05, em sequência e a partir de 01. |
.sync-title |
1 a 3 palavras. É um rótulo de fase, não uma frase: "Empresa e liquidez", nunca "Como cuidamos da sua empresa". |
.sync-text |
60 a 90 palavras. Abaixo de 50 o bloco boia no meio de 70vh de vazio; acima de 100 ele estoura a altura do passo e deixa de ficar centrado. |
.sync-cap-pill |
Não se escreve: o script copia o .sync-title do passo ativo. No markup ela traz o título do primeiro passo, e só, para a dobra ficar certa sem JavaScript. |
Comprimento parecido é estrutural, em dois lugares. No desktop cada passo tem 70vh fixos, então um texto muito mais curto que os outros aparece como um buraco. No celular a altura do passo é a altura do texto, e é ela que decide onde cai o ponto do trilho em relação ao preenchimento: medido a 390×844, com os quatro parágrafos deste markup (passos de 443, 443, 418 e 418px), o ponto e a passagem do preenchimento se separam por no máximo 1,1 ponto percentual — invisível. Esse desvio cresce na proporção da diferença de altura entre os blocos.
Os quatro títulos têm de ser paralelos. Todos substantivos ("Formação", "Sucessão e legado") ou todos verbos. Misturar as duas formas quebra a leitura da série, que é a única coisa que segura quatro blocos separados por meia tela de vazio.
Especificação dos assets
- Formato e dimensão mínima: retrato, 900×1200, WebP. Uma por passo.
- Peso alvo: até 180 KB cada. As quatro carregam juntas — ver o gotcha do
lazy. - Composição: a foto tem de sobreviver a dois recortes muito diferentes. No desktop a caixa é ~489×630 (proporção 0,78, quase o retrato original); no celular é ~350×287 (proporção 1,22, uma paisagem tirada de um retrato). Assunto no terço central vertical, com folga dos dois lados. Cada
<img>declara o seu corte em--sync-pos. - Tratamento igual nas quatro: mesma temperatura de cor, mesmo contraste, mesma quantidade de grão. A fusão de 0,9s coloca duas fotos na mesma caixa ao mesmo tempo, e qualquer diferença de tom aparece exatamente aí.
- O que não pode ter: texto embutido, marca d'água, ou assunto encostado na borda de baixo — é onde mora a legenda.
Como usar
<link rel="stylesheet" href="componentes/conteudo/rolagem-sincronizada/estilo.css">
<script src="componentes/conteudo/rolagem-sincronizada/script.js"></script>
Um passo é sempre três coisas juntas e na mesma ordem: o <li data-sync-step>, a <img data-sync-img> dentro do painel e o <span data-sync-dot> dentro do trilho.
<li class="sync-step" data-sync-step>…</li>
<img class="sync-img" data-sync-img style="--sync-pos:50% 38%" …>
<span class="sync-dot" data-sync-dot style="--sync-i:1"></span>
Ganchos
| Gancho | Papel |
|---|---|
[data-sync] |
Raiz. O script parte daqui e nunca do documento — a dobra pode repetir na página. |
[data-sync-step] |
Cada bloco de texto. É o que o IntersectionObserver observa. |
[data-sync-img] |
Cada foto. A ordem tem de bater com a dos passos. |
[data-sync-dot] |
Cada ponto do trilho. --sync-i no style, a partir de 0 e em sequência. |
[data-sync-fill] |
A barra que --sync-p preenche. |
[data-sync-cap] |
A pílula da legenda. O script reescreve o texto lendo o <h3> ativo. |
--sync-step-h |
Altura mínima de cada passo. 70vh. É o tempo de leitura antes da próxima troca. |
--sync-panel-h |
Altura do painel pinado. min(70vh,640px). |
--sync-top |
Onde o painel gruda. calc(var(--nav-h) + 24px). |
--sync-fade |
Duração da fusão. .9s. A escala leva 1,5× isso. |
--sync-rail-w |
Espessura do fio e largura da coluna do meio do grid. 2px; 0px tira o trilho. |
--sync-band |
Onde começa a faixa de leitura, em % da tela, número puro. 45 no desktop, 58 abaixo de 900px. A faixa tem sempre 10%. |
--sync-slot |
Fatia do trilho por passo. 25% no CSS; o script reescreve com a contagem real. |
--sync-p |
Progresso de 0 a 1. Escrito pelo script a cada quadro de rolagem. |
--sync-panel-m / --sync-top-m |
Altura e topo do painel abaixo de 900px. 38vh (34vh abaixo de 560px) e 0px. |
--sync-pos |
Recorte de cada foto (object-position), no style da própria <img>. |
.is-active / .is-on |
No passo ativo / na foto e no ponto dele. |
.is-flipped |
Na seção: painel à esquerda, texto à direita. |
Variações
- Três a cinco passos — acrescente ou remova os três elementos juntos. O trilho se reparte sozinho: o script recalcula
--sync-slotpela contagem real do DOM. - Painel à esquerda —
.is-flippedna seção. No celular não muda nada. - Sem trilho — apague o
.sync-raile ponha--sync-rail-w:0px. Sobra um corredor de dois gaps entre as colunas. - Passos mais curtos —
--sync-step-h:50vhencurta a dobra em uma tela e acelera a troca. Encurte o texto junto. - Em seção clara —
.sec-paperno lugar de.sec-dark. Só a legenda não muda: ela lê--lightde propósito.
Gotchas
-
O painel não gruda por causa do
position:sticky; ele gruda porque tem altura menor que a área de grid dele. Sticky move o elemento dentro do bloco que o contém. As quatro combinações, medidas a 1440×900 comparando o topo do painel emscrollY1200 e 1600:start+ altura → 108px e 108px (certo);stretch+ altura → 108px e 108px (funciona, porque altura definida vence ostretch);startsem altura → gruda, mas com 0px de altura (as fotos são absolutas e o painel colapsa);stretchsem altura → −807px e −1207px, painel de 2 520px — ele vira a coluna inteira e desce junto com a página. Oalign-items:starte oheightexplícito ficam os dois porque quem adapta mexe num e não no outro. -
Um
overflowdiferente devisibleem qualquer ancestral mata o sticky. Medido nos mesmos dois pontos:overflow:hiddenna seção derruba o painel para −807px e −1207px; no.sync-grid, para −699px e −1099px. Por isso não háoverflow:hiddenem lugar nenhum desta dobra — nem para conter o blur das fotos, que já é contido pelo próprio painel. -
O
overflow-x:hiddenque o core põe no<body>não conta, e vale saber por quê antes de tentar "consertar". Com o<html>emvisible, o navegador propaga o overflow do<body>para a viewport e o<body>volta a se comportar comovisible. Medido: o computed style do body saihidden/autoe o painel continua cravado em 108px. O mesmo já valia para olistas/baralho-sticky. -
Saltar por cima da coluna deixava a foto errada na tela — foi um bug de verdade, e o conserto não é o observador. Os passos são contíguos, então enquanto o ponteiro está dentro da coluna sempre há um deles cobrindo a faixa; fora dela não há nenhum, e o
IntersectionObservernão dispara. Num salto (âncora, arrasto da barra, restauração de posição ao voltar), os passos do meio nunca cruzam a faixa. Medido antes do conserto: um salto direto para 98% da página deixava o passo 04 escrito na coluna e a foto 01 no painel, com a legenda dizendo "Formação". Quem tapa é oresolverPonta(): quando a cobertura está toda zerada, o progresso já está cravado em 0 ou 1 eMath.floor(p * n)devolve a ponta certa. Ele é chamado dos dois lados — do observador e do quadro de rolagem — porque a ordem entre eles não é garantida, e num salto o quadro pode rodar antes de o observador ter zerado a cobertura. -
A escolha do passo ativo usa
intersectionRect.height, nãointersectionRatio. Oratioé a fração do passo que está na faixa, então um passo mais alto sai com ratio menor cobrindo exatamente a mesma faixa. O que interessa é quanto da faixa cada passo ocupa. No celular, onde a altura do passo é a altura do texto, oratioescolheria sistematicamente o passo mais curto. -
A faixa do observador desce no celular, e quem decide isso é o CSS.
--sync-bandé um número puro que o script lê, e a media query o troca de 45 para 58. Motivo: no desktop a zona de leitura é a tela toda e o meio dela é o meio da tela; no celular o painel come os 34–38% de cima e a leitura acontece por volta de 69% da altura. Medido a 390×844 com a faixa em 45: a foto trocava com o parágrafo já ~100px acima de onde o olho estava — sempre atrasada. O script relê o valor a cadaresize, e reconstrói o observador só quando ele muda. -
A faixa tem sempre 10% da tela, e o passo tem de ser mais alto que ela. A 900px de altura isso é 90px; a 844px, 84px. Passo mais curto que a faixa pode nunca ser o que mais a cobre e ser pulado.
--sync-step-h:70vhestá 7× acima do piso; no celular, onde vale a altura do texto, os quatro passos deste markup medem 443/443/418/418px. O piso útil de--sync-step-hé ~20vh. -
O ponto do trilho e o preenchimento coincidem por construção, não por ajuste. O ponteiro do progresso é o centro da faixa (
--sync-band + 5), não o centro da tela; e cada ponto fica no centro da fatia do seu passo. Com passos de altura igual, o preenchimento cruza o ponto exatamente quando aquele passo entra na faixa. Trocar o ponteiro porinnerHeight / 2desalinha os dois no celular, onde a faixa não está no meio da tela. -
O preenchimento do trilho não tem
transition, e isso é deliberado. O valor é reescrito a cada quadro de rolagem; uma transição por cima faria a barra chegar atrasada em relação ao dedo — um indicador de posição que mente. -
Com movimento reduzido o trilho continua contínuo. É a única decisão discutível do componente, e a razão é esta: a preferência existe para eliminar movimento que a pessoa não pediu, e o trilho é uma leitura 1:1 do gesto que ela está fazendo, como uma barra de rolagem — que nenhum sistema operacional esconde sob
reduce. Fazê-lo saltar de passo em passo removeria informação (a que diz quanto falta dentro do bloco atual) e introduziria um salto, que é mais brusco que o deslizar que ele substituiria. O que a preferência desliga, medido: a foto troca comfilter:none,transform:noneetransition:opacity .3s linear, e o ponto ativo perde oscale(1.55)e fica só com a cor. -
As três fotos com
loading="lazy"carregam mesmo emopacity:0. Medido no carregamento, a 1440×900 e com a página no topo: as quatro saem comnaturalWidth:900ecomplete:true.lazyé sobre proximidade da viewport, não sobre visibilidade — e as quatro estão empilhadas dentro do painel, que já está na tela. O atributo aqui só protege quem colar a dobra no fim de uma página longa. É o motivo do teto de 180 KB por foto: no pior caso as quatro chegam juntas. -
As três fotos em repouso ficam com
filter:blur(12px)permanente, e 12px é teto. É a mesma solução doconteudo/apresentacao-foto-alternada: a que entra precisa ter de onde sair.opacity:0não garante que o navegador pule o filtro. Com quatro fotos e 12px o custo é previsível; acima de seis, troque a fusão por opacidade pura. -
Sem JavaScript a dobra continua inteira, e isso foi medido, não suposto. O primeiro passo nasce com
.is-active, a primeira foto com.is-on, a pílula com o título dele e os pontos nos quartos certos (o--sync-slot:25%do CSS). O trilho aparece vazio (scaleY(0)), o que é honesto — não há progresso medido. Os três passos em repouso ficam em--fg-muted: 6,05:1 sobre--dark, acima do mínimo AA para texto normal. Para referência, o passo ativo mede 17,39:1 no título, 11,85:1 no texto e 12,08:1 no número. -
A legenda é
aria-hiddende propósito. Ela repete o<h3>do passo ativo, que está a um palmo dali na coluna de texto; anunciá-la faria o leitor de tela dizer cada título duas vezes. Pelo mesmo motivo não háaria-live— não há informação nova para anunciar. O trilho inteiro também éaria-hidden: o que ele carrega ("em que passo estou, quanto falta") já está no<ol>. -
O número visível (
01) NÃO éaria-hidden, apesar de a lista já numerar. O Safari descarta a semântica de lista quandolist-styleénone— e énoneaqui. Nesse caso o número visível vira a única pista de ordem que sobra. O custo é ouvir "01" antes do título nos navegadores que mantêm a semântica; é barato perto de perder a ordem inteira. -
A dobra não tem nenhum elemento focável, e isso é correto. Medido: zero
a,button,inputou[tabindex]na seção. Não há o que operar — quem chega porTabatravessa a dobra e continua, e o texto todo já está no DOM em ordem de leitura. Se um passo precisar de um link, ele entra dentro do.sync-step-body, nunca no painel. -
No celular os três blocos dividem a mesma célula do grid (
grid-area:1/1), e isso não é economia de markup. Se o painel tivesse linha própria, o bloco que o contém seria essa linha — 34vh — e ele sairia de cena no primeiro gesto. Quem empurra o texto para baixo do painel é opadding-topdos passos (medido: 306,96px = 287 do painel + 20 de respiro); quem fica por cima é oz-index. O texto passa por trás da foto, que é o efeito. -
A barra de progresso do celular corre 10px por dentro da imagem, não colada na base dela. Os pontos têm 7px e ficam centrados no fio: na base exata avançavam 2,5px sobre a primeira linha de texto que sai de baixo do painel, cortando os ascendentes. Recuada para dentro, a barra lê como o marcador de um player e o texto passa limpo. Sobre foto o trilho também não pode ler
--rule— some em foto escura e suja em foto clara; ali ele é--lighttranslúcido, pela mesma lógica da legenda. -
O painel gruda e solta de forma simétrica, e é o que faz o último passo não ficar órfão. Ele prende quando o topo da coluna chega a
--sync-tope solta quando o fim dela chega a--sync-top + --sync-panel-h. Com os padrões daqui, a 1440×900: prende 27px depois de o passo 01 ficar centrado e solta 27px depois de o passo 04 ficar centrado. Essa simetria depende de todos os passos terem a mesma altura e o conteúdo deles estar centrado na vertical — mexer num dos dois desequilibra as pontas. -
A conta que precisa fechar é
--nav-h + 24px + --sync-panel-h ≤ altura da tela. Com os padrões (84 + 24 +min(70vh,640px)) isso vale a partir de 360px de altura de tela. Subir--nav-hsem baixar--sync-panel-hfaz o painel passar do rodapé da viewport e a última faixa da foto nunca aparecer.
Histórico
| Projeto | Onde | Data | Diferenças |
|---|---|---|---|
| autoral | construído do zero na biblioteca | 2026-09-03 | — |
Origem. É o padrão que se chama scrollytelling de duas colunas com painel de mídia pinado: um lado rola, o outro fica preso e troca. Aparece nas páginas de produto da Apple (a seção em que a arte muda enquanto o texto passa), nos blocos de "como funciona" da Stripe, nos apps de banco digital brasileiros e em dezenas de peças premiadas no Awwwards. Não há referência única. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O registro consolidado vai em projetos/autorais.md.
Decisões de tradução para o contrato.
- O que virou token: todas as cores (o passo em repouso lê
--fg-muted, o ativo--fge--fg-2, o número e o trilho preenchido leem--accent, o fio vazio--rule, o ponto em repouso--rule-2); a família e a curva (--display,--sans,--ease); o canto do painel (--radius-media) e o dos pontos (--radius-pill); e o topo do sticky, que lê--nav-hem vez de um número — é o que faz a dobra funcionar em projeto com menu fixo alto e baixo sem tocar no CSS dela. - O que virou variável: os dezoito ganchos da tabela acima, com destaque para dois.
--sync-bandé a decisão de onde fica a zona de leitura, e mora no CSS justamente para que a media query — e não o script — a mude na virada de layout.--sync-rail-walimenta ao mesmo tempo a espessura do fio e a largura da coluna do meio do grid, para as duas não poderem divergir. - O que virou gancho do core: a entrada é
data-rv/data-de.ttl, nunca observer próprio; a leitura de rolagem assinaLIB.onScroll(com plano B emscrollpassive + rAF para o componente colado sem omotion.js); a legenda reaproveita a.pill; o ruído sobre a foto é o.grain; a coluna é o.wrape o ritmo é o.sec-pad. A fusão com blur é a mesma doconteudo/apresentacao-foto-alternada— ideia reaproveitada, código não: os prefixos são outros. - O recorte por foto virou
--sync-posnostyleda própria<img>, como o--pil-posdoconteudo/pilares-texto-na-foto. É o que impede o acoplamento entre a ordem dos passos e o CSS. - Acessibilidade que o padrão comum não tem: a lista é um
<ol>de verdade; o número visível fica anunciado por causa do Safari; a legenda que troca éaria-hiddenpor ser cópia do<h3>vizinho, em vez de virar umaria-liveque anunciaria tudo duas vezes; cada foto temaltpróprio (são quatro assuntos diferentes, ao contrário da dobra irmã, onde são o mesmo assunto e oaltrepetido seria ruído); e sem JavaScript a dobra continua legível a 6,05:1, com a foto e o passo certos. - O que não atravessou: nada de biblioteca. O padrão costuma vir com GSAP
ScrollTriggeremscrub; aqui a única conta por quadro é uma subtração e uma divisão, e o resto éposition:stickycomIntersectionObserver.