← Biblioteca componentes/conteudo/rolagem-sincronizada/ficha.md CódigoTela cheia
conteudo/rolagem-sincronizada

Rolagem sincronizada com painel pinado

🟢 estavel complexidade alta prefixo sync- scrollytelling sticky rolagem passos fases jornada duas-colunas foto crossfade progresso intersection-observer sincronizado

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/conteudo/rolagem-sincronizada/estilo.css">
<script src="componentes/conteudo/rolagem-sincronizada/script.js"></script>

markup.html · 160 linhas · 8,0 KB

<!-- ==========================================================================
     ROLAGEM SINCRONIZADA — quatro blocos de texto, um painel de foto pinado
     Prefixo: sync-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Usa as primitivas .wrap, .sec-pad, .eyebrow, h2.sec-title, .ttl, .pill e
     .grain do core. Sem lib.

     A DOBRA. Duas colunas e um fio no meio. À esquerda, quatro blocos altos
     que passam rolando; à direita, um painel de foto que fica pinado no topo
     e troca de imagem quando o bloco correspondente chega à faixa de leitura.
     É um carrossel que a pessoa dirige com a rolagem, sem perceber que é um
     carrossel: não há seta, indicador nem nada para operar.

     UMA ÁRVORE, DOIS LAYOUTS. Não existe versão mobile do markup. Abaixo de
     900px o mesmo HTML vira uma coluna com o painel pinado no topo e o texto
     passando por baixo dele — os três blocos (passos, trilho e painel) caem
     na MESMA célula do grid, e é a sobreposição que dá ao painel altura de
     sobra para grudar. Ver o comentário do estilo.css.

     A ORDEM DO DOM É A ORDEM DE LEITURA: passos, trilho, painel. Quem chega
     por leitor de tela ouve os quatro textos inteiros antes de chegar às
     fotos, e as fotos são ilustração, não conteúdo novo. O painel só aparece
     antes do texto na TELA do celular, e isso é posicionamento, não ordem.

     SEM JAVASCRIPT a dobra continua inteira: o primeiro passo já nasce com
     .is-active, a primeira foto com .is-on e a pílula com o título dele. O
     que se perde é a troca — os outros três textos ficam em tinta de apoio
     (--fg-muted, 6.05:1 sobre --dark, acima do mínimo de AA) e a foto não
     muda. Nada some, nada fica invisível.

     A PÍLULA da legenda repete o título do passo ativo, que está a um palmo
     dali na coluna de texto. Por isso a legenda é aria-hidden: anunciá-la
     faria o leitor de tela dizer cada título duas vezes. O script reescreve
     o texto dela lendo o próprio .sync-title — o título mora num lugar só.

     O NÚMERO ("01") não é aria-hidden, apesar de o <ol> já numerar. O Safari
     descarta a semântica de lista quando list-style é none, e nesses casos o
     número visível passa a ser a única pista de ordem. Ver a ficha.

     FOTOS. Retrato, uma por passo, assuntos diferentes — por isso cada uma
     tem alt de verdade, ao contrário do conteudo/apresentacao-foto-alternada,
     onde as fotos são o mesmo assunto e repetir o alt seria ruído. O recorte
     de cada uma vem no style, em --sync-pos, e não numa regra por posição:
     assim trocar a ordem dos passos não mexe no CSS.
     ========================================================================== -->

<section class="sec-dark sec-pad sync" data-sync>

  <div class="wrap sync-head">
    <span class="eyebrow" data-rv>Fases</span>
    <h2 class="sec-title ttl">Uma assessoria para cada <em>fase</em> da vida</h2>
    <p class="sec-lead" data-rv data-d="1">A prioridade muda a cada década. A estrutura que a sustenta, não.</p>
  </div>

  <div class="wrap sync-grid">

    <ol class="sync-steps">

      <li class="sync-step is-active" data-sync-step>
        <div class="sync-step-body" data-rv>
          <span class="sync-num">01</span>
          <h3 class="sync-title">Formação</h3>
          <p class="sync-text">
            O primeiro milhão vem do salário; o segundo, da disciplina. Nesta
            fase o trabalho é construir o hábito antes de construir a carteira:
            reserva de emergência dimensionada, aporte mensal automático e uma
            previdência escolhida pela taxa, não pelo banco. A alocação começa
            simples e ganha camadas conforme o patrimônio cresce. O plano
            acompanha a vida — casamento, filhos, mudança de cidade — e é
            revisto quando ela muda, não quando o mercado muda.
          </p>
        </div>
      </li>

      <li class="sync-step" data-sync-step>
        <div class="sync-step-body">
          <span class="sync-num">02</span>
          <h3 class="sync-title">Empresa e liquidez</h3>
          <p class="sync-text">
            A empresa é o maior ativo da família, e costuma ser o menos
            diversificado. Aqui entram as decisões que só aparecem uma vez: a
            venda de participação, a entrada de um sócio, o caixa que a operação
            não usa e o câmbio de um recebimento em dólar. Cada uma tem janela
            própria e efeito tributário próprio. O comitê de investimentos senta
            antes do evento, não depois — a estrutura que protege o resultado
            precisa existir na assinatura.
          </p>
        </div>
      </li>

      <li class="sync-step" data-sync-step>
        <div class="sync-step-body">
          <span class="sync-num">03</span>
          <h3 class="sync-title">Sucessão e legado</h3>
          <p class="sync-text">
            Sucessão bem-feita é a que ninguém precisa discutir no pior dia. A
            holding familiar organiza o que já existe, o testamento diz o que a
            lei não adivinha e a governança define quem decide o quê quando os
            herdeiros forem muitos. Nada disso é sobre morrer: é sobre deixar o
            patrimônio operável por quem fica. Revisamos a estrutura a cada
            mudança de família ou de regra tributária, com relatório consolidado
            de tudo que está sob custódia.
          </p>
        </div>
      </li>

      <li class="sync-step" data-sync-step>
        <div class="sync-step-body">
          <span class="sync-num">04</span>
          <h3 class="sync-title">Próxima geração</h3>
          <p class="sync-text">
            Herdeiro preparado custa menos que estrutura blindada. A conversa
            começa cedo: o que a família tem, como foi construído e o que se
            espera de quem recebe. Montamos o conselho de família, damos
            educação financeira aos mais novos na linguagem deles e ajudamos a
            desenhar a filantropia — o veículo, a causa e a regra de doação. É a
            fase em que o assessor fala menos com você e mais com quem vem
            depois.
          </p>
        </div>
      </li>

    </ol>

    <!-- TRILHO. Puramente gráfico — a informação que ele carrega (em que passo
         estou, quanto falta) já está no texto e no <ol>. O preenchimento é
         contínuo e escrito pelo script; os quatro pontos são as estações, e o
         do passo ativo acende. --sync-i numera a partir de 0, em sequência,
         como o --deck-i do listas/baralho-sticky. -->
    <div class="sync-rail" aria-hidden="true">
      <span class="sync-rail-fill" data-sync-fill></span>
      <span class="sync-dot is-on" data-sync-dot style="--sync-i:0"></span>
      <span class="sync-dot" data-sync-dot style="--sync-i:1"></span>
      <span class="sync-dot" data-sync-dot style="--sync-i:2"></span>
      <span class="sync-dot" data-sync-dot style="--sync-i:3"></span>
    </div>

    <figure class="sync-panel">
      <img class="sync-img is-on" data-sync-img style="--sync-pos:50% 42%"
           src="../../../showroom/demo/ph-2.webp"
           alt="Família caminhando por uma alameda arborizada" decoding="async">
      <img class="sync-img" data-sync-img style="--sync-pos:50% 38%"
           src="../../../showroom/demo/ph-7.webp"
           alt="Reunião de trabalho em uma sala de vidro" loading="lazy" decoding="async">
      <img class="sync-img" data-sync-img style="--sync-pos:50% 34%"
           src="../../../showroom/demo/ph-5.webp"
           alt="Homem mais velho e uma criança lendo juntos" loading="lazy" decoding="async">
      <img class="sync-img" data-sync-img style="--sync-pos:50% 46%"
           src="../../../showroom/demo/ph-1.webp"
           alt="Casal em uma varanda com vista para o vilarejo" loading="lazy" decoding="async">

      <span class="grain"></span>

      <figcaption class="sync-cap" aria-hidden="true">
        <span class="pill sync-cap-pill" data-sync-cap>Formação</span>
      </figcaption>
    </figure>

  </div>
</section>

estilo.css · 383 linhas · 17,5 KB

/* ==========================================================================
   ROLAGEM SINCRONIZADA — prefixo sync-
   Quatro blocos de texto altos rolando de um lado, um painel de foto pinado
   do outro, e um fio de progresso entre os dois.

   Tokens usados:
     --nav-h                 de onde sai o topo do painel pinado
     --ease                  curva única do projeto
     --display --sans        tipografia (número e título / texto)
     --accent                trilho preenchido, ponto ativo, número do passo ativo
     --fg --fg-2 --fg-muted  passo ativo / corpo do ativo / passos em repouso
     --rule --rule-2         trilho vazio e ponto em repouso; fundo do painel
     --bg                    halo que separa o ponto do fio
     --dark-rgb              véu da legenda sobre a foto
     --light                 texto da legenda (ilha escura) e trilho sobre foto
     --radius-media          canto do painel
     --radius-pill           os pontos do trilho

   O COMPONENTE NÃO DECLARA ESCOPO DE TOM. A seção declara (.sec-dark no
   markup, .sec-deep ou .sec-paper valem igual) e tudo aqui herda. A única
   coisa que não herda é o que está DENTRO da foto: a legenda lê --light, como
   manda o §6 do contrato para texto sobre imagem, porque ali a ilha é escura
   independentemente do tom da seção.

   O QUE FAZ O PAINEL GRUDAR NÃO É SÓ O position:sticky. Sticky move o
   elemento DENTRO do bloco que o contém, nunca para fora dele — então o
   painel precisa ter altura MENOR que a área de grid dele. Quem garante isso
   são duas coisas juntas, o align-items:start e a altura explícita do painel,
   e as quatro combinações foram medidas nesta dobra, a 1440x900, comparando o
   topo do painel em scrollY 1200 e 1600:

     start   + altura   ->  108px e 108px       cravado, é o que se quer
     stretch + altura   ->  108px e 108px       a altura definida já vence o
                                                stretch; funciona por sorte
     start   + sem altura -> 108px, mas 0px de altura: as fotos são absolutas
                                                e o painel colapsa
     stretch + sem altura -> -807px e -1207px, painel de 2 520px: ele vira a
                                                coluna inteira e desce junto
                                                com a página

   Ou seja: o alinhamento sozinho não basta e a altura sozinha não basta. As
   duas ficam, e a segunda linha da tabela é o motivo — quem for adaptar mexe
   numa e não na outra, e a que sobra tem de segurar.

   O QUE MATA O STICKY é overflow diferente de visible em QUALQUER ancestral:
   ele vira o contêiner de rolagem do painel, e como esse contêiner não rola,
   o painel nunca gruda. Medido nesta dobra, nos mesmos dois pontos de
   rolagem: um overflow:hidden na seção derruba o painel para -807px e
   -1207px; no .sync-grid, para -699px e -1099px. Por isso não há
   overflow:hidden em lugar nenhum daqui — nem para conter o blur das fotos,
   que já é contido pelo próprio painel.

   O overflow-x:hidden que o core/base.css põe no <body> NÃO conta, e é bom
   saber por quê antes de tentar "consertar": com o <html> em visible, o
   navegador propaga o overflow do <body> para a viewport e o próprio <body>
   volta a se comportar como visible. Medido: computed style do body sai
   hidden/auto e o painel continua cravado em 108px.
   ========================================================================== */

.sync{
  /* Altura de cada bloco de texto. É o que dá tempo de leitura antes da
     próxima troca de foto — e é o que garante que o passo seja mais alto que
     a faixa de 10% do observador (ver --sync-band). Abaixo de ~20vh a dobra
     vira um flip-book. */
  --sync-step-h:70vh;

  /* Altura do painel. O teto em px existe para telas muito altas: 70vh de um
     monitor de 1440 seriam 1008px de foto, mais alto que qualquer retrato
     enquadrado bem. */
  --sync-panel-h:min(70vh,640px);

  /* Onde o painel gruda. Lê a altura do menu fixo do projeto e acrescenta um
     respiro. A conta que precisa fechar é --nav-h + 24px + --sync-panel-h <=
     altura da tela; com os padrões daqui isso vale para telas a partir de
     360px de altura. */
  --sync-top:calc(var(--nav-h) + 24px);

  /* Duração da fusão entre uma foto e a próxima. A escala leva 1,5x isso,
     para a imagem continuar assentando depois de já estar nítida. */
  --sync-fade:.9s;

  /* Espessura do fio, e também a largura da coluna do meio no grid — os dois
     saem daqui para não poderem divergir. Em 0px a dobra fica sem trilho e o
     que sobra é um corredor de dois gaps entre as colunas (ver a variação
     "sem trilho" na ficha). */
  --sync-rail-w:2px;

  /* Onde COMEÇA a faixa de leitura do observador, em % da altura da tela.
     A faixa tem sempre 10% de altura: 45 quer dizer de 45% a 55%, o meio
     exato da tela. Lido pelo script — número puro, sem unidade. No celular o
     painel ocupa o topo, e a faixa desce (ver a media query do fim). */
  --sync-band:45;

  /* Fatia do trilho por passo: 100% dividido pelo número de passos. O script
     reescreve a partir da contagem real do DOM, então este valor só vale
     enquanto o JS não rodou (ou se ele não estiver na página). Multiplicação
     e não divisão de propósito: calc(N * 25%) é sempre válido, calc(100% /
     var(--x)) depende de o navegador aceitar divisão por variável. */
  --sync-slot:25%;

  /* Progresso do trilho, de 0 a 1. Escrito pelo script a cada quadro de
     rolagem. Sem JS fica em 0 e o trilho aparece vazio — o que é honesto:
     não há progresso medido. */
  --sync-p:0;

  /* Os dois de baixo só valem abaixo de 900px, onde o painel vai para o topo.
     Ficam aqui, na raiz, para serem ajustáveis sem entrar na media query.
     Site com menu fixo também no celular sobe --sync-top-m para a altura
     dele; --sync-panel-m é o quanto da tela a foto ocupa lá. */
  --sync-panel-m:38vh;
  --sync-top-m:0px;
}

/* ---------------------------------------------------------------- cabeçalho */
/* Alinhado à esquerda, e não .sec-head do core: .sec-head é cabeçalho
   CENTRADO, e aqui ele abre uma coluna de leitura que também é à esquerda.
   Centrar o título e alinhar o texto à esquerda logo abaixo faz a dobra
   começar torta. */
.sync-head{margin-bottom:clamp(40px,6vh,72px)}

/* ---------------------------------------------------------------- grid */
/* Três colunas: texto, o fio de 2px, painel. O gap vale dos dois lados do
   fio, então ele nasce no meio de um corredor de 2*gap + 2px — é o que o faz
   ler como divisória e não como borda de uma das colunas.
   minmax(0,1fr) e não 1fr: sem o minmax, uma palavra longa no parágrafo
   empurra a coluna de texto e o painel encolhe. */
.sync-grid{
  display:grid;
  grid-template-columns:minmax(0,1fr) var(--sync-rail-w) minmax(0,1fr);
  gap:clamp(28px,4vw,64px);
  align-items:start;
}

/* Inverter os lados: painel à esquerda, texto à direita. O fio fica onde
   está — só as duas pontas trocam de ordem. Mesmo gancho e mesma convenção
   do conteudo/apresentacao-foto-alternada e do conteudo/imagem-lista-numerada,
   para as dobras de uma mesma página virarem todas pelo mesmo nome. */
.sync.is-flipped .sync-panel{order:-1}
.sync.is-flipped .sync-steps{order:1}

/* ---------------------------------------------------------------- passos */
/* É um <ol>: a ordem é conteúdo, não decoração. O reset do core zera as duas. */
.sync-steps{min-width:0}

/* O texto fica CENTRADO na altura do passo, e não no topo dele. É o que faz
   o bloco cruzar a faixa de leitura no meio da tela ao mesmo tempo em que a
   pessoa está lendo, em vez de trocar a foto uma tela antes. */
.sync-step{
  min-height:var(--sync-step-h);
  display:flex;align-items:center;
}

.sync-step-body{
  display:flex;flex-direction:column;align-items:flex-start;
  gap:clamp(12px,1.4vw,18px);
}

/* Os três elementos do passo nascem em tinta de apoio e acendem juntos. A
   transição é de COR e só de cor: não há deslocamento aqui, e é por isso que
   ela continua valendo com movimento reduzido — apagar e acender texto não é
   movimento, é o próprio estado do componente. */
.sync-num{
  font-family:var(--display);font-weight:700;
  font-size:12px;letter-spacing:.16em;line-height:1;
  text-transform:uppercase;
  color:var(--fg-muted);
  transition:color .5s var(--ease);
}
.sync-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(24px,2.4vw,34px);line-height:1.18;letter-spacing:-.022em;
  color:var(--fg-muted);
  transition:color .5s var(--ease);
}
/* 54ch segura a linha antes de a leitura cansar, mesmo em tema de coluna
   larga; na coluna padrão (~490px) o parágrafo enche a metade e para. */
.sync-text{
  font-family:var(--sans);
  font-size:17px;line-height:1.62;
  max-width:54ch;
  color:var(--fg-muted);
  transition:color .5s var(--ease);
}

/* O passo ativo não é um bloco monocromático em --fg: o título vai a --fg, o
   corpo a --fg-2 e o número à cor de marca. É a mesma hierarquia do
   h2.sec-title + .sec-lead do core — texto longo em --fg cheio pesa. */
.sync-step.is-active .sync-num{color:var(--accent)}
.sync-step.is-active .sync-title{color:var(--fg)}
.sync-step.is-active .sync-text{color:var(--fg-2)}

/* ---------------------------------------------------------------- trilho */
/* align-self:stretch vence o align-items:start do grid, e é o único item que
   quer a linha inteira: o fio tem de ser tão alto quanto a pilha de passos,
   porque é a régua deles. */
.sync-rail{
  position:relative;
  width:var(--sync-rail-w);
  align-self:stretch;
  background:var(--rule);
}

/* Preenchimento contínuo. Sem transition de propósito: o valor é reescrito a
   cada quadro de rolagem, e uma transição por cima faria a barra chegar
   atrasada em relação ao dedo — um indicador de posição que mente. */
.sync-rail-fill{
  position:absolute;inset:0;
  background:var(--accent);
  transform:scaleY(var(--sync-p,0));
  transform-origin:top;
  will-change:transform;
}

/* As estações. Cada ponto no centro da fatia do seu passo, que é exatamente
   onde o preenchimento chega quando aquele passo entra na faixa de leitura —
   os dois indicadores concordam por construção, não por ajuste.
   O halo em --bg é o que separa o ponto do fio; sem ele o ponto em repouso
   some dentro da parte já preenchida do trilho. */
.sync-dot{
  position:absolute;left:50%;
  top:calc((var(--sync-i,0) + .5) * var(--sync-slot));
  width:7px;height:7px;
  border-radius:var(--radius-pill);
  background:var(--rule-2);
  box-shadow:0 0 0 3px var(--bg);
  transform:translate(-50%,-50%);
  transition:background-color .45s var(--ease),transform .45s var(--ease);
}
.sync-dot.is-on{background:var(--accent);transform:translate(-50%,-50%) scale(1.55)}
/* O ponto muda de tamanho, e tamanho é deslocamento: some com movimento
   reduzido. A cor fica — é ela que diz onde a pessoa está. */
@media(prefers-reduced-motion:reduce){
  .sync-dot{transition:background-color .2s linear}
  .sync-dot.is-on{transform:translate(-50%,-50%)}
}

/* ---------------------------------------------------------------- painel */
/* margin:0 porque é um <figure> e o navegador dá 1em de cada lado.
   overflow:hidden AQUI é obrigatório e é seguro: recorta o blur das fotos
   que entram (que sangra ~3x o raio para fora da caixa) sem virar contêiner
   de rolagem de nenhum ancestral — o sticky é DESTE elemento, e o overflow
   de um elemento não afeta a própria fixação dele.
   O fundo em --rule evita o buraco de meio segundo entre o layout e a
   decodificação da primeira foto: as quatro são absolutas e não sustentam
   altura nenhuma. */
.sync-panel{
  position:sticky;top:var(--sync-top);
  height:var(--sync-panel-h);
  margin:0;
  overflow:hidden;
  border-radius:var(--radius-media);
  background:var(--rule);
  z-index:2;
}

/* As quatro no mesmo quadro; só a .is-on aparece. A que entra carrega blur e
   um resto de escala indo a zero, a que sai faz o caminho inverso: em nenhum
   quadro existem duas fotos nítidas ao mesmo tempo (contrato §5, nada entra
   seco). O recorte vem por foto, em --sync-pos no style — retrato de rua tem
   o assunto num terço, foto de mesa no meio, e cravar um valor único
   decapitaria uma das duas famílias. */
.sync-img{
  position:absolute;inset:0;
  width:100%;height:100%;object-fit:cover;
  object-position:var(--sync-pos,50% 42%);
  opacity:0;filter:blur(12px);transform:scale(1.04);
  transition:opacity var(--sync-fade) var(--ease),
             filter var(--sync-fade) var(--ease),
             transform calc(var(--sync-fade) * 1.5) var(--ease);
}
.sync-img.is-on{opacity:1;filter:blur(0);transform:scale(1)}
/* Com movimento reduzido sobra a fusão de opacidade, curta e sem curva: sem
   blur (que é uma escala disfarçada) e sem escala. Trocar em corte seco seria
   pior — um piscar é mais agressivo que um esmaecer de .3s. */
@media(prefers-reduced-motion:reduce){
  .sync-img{filter:none;transform:none;transition:opacity .3s linear}
  .sync-img.is-on{filter:none;transform:none}
}

/* Legenda DENTRO da foto, ancorada embaixo, sobre gradiente com blur
   mascarado — contrato §6. A máscara não é enfeite: sem ela o backdrop-filter
   termina em borda dura no meio da imagem e a faixa lê como adesivo. */
.sync-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  margin:0;
  display:flex;
  padding:60px 22px 20px;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.9) 0%,
    rgba(var(--dark-rgb),.5) 48%,
    transparent 100%);
  backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
}

/* Reaproveita a .pill do core: forma, respiro e tracking vêm de lá. Só o tom
   muda, porque a pílula está sobre foto e não sobre a seção — é a mesma
   solução do midia/galeria-scroll-horizontal. */
.sync-cap-pill{
  color:var(--light);
  border-color:color-mix(in srgb,var(--light) 34%,transparent);
}

/* ---------------------------------------------------------------- mobile */
/* UMA COLUNA, e os três blocos na MESMA célula do grid (grid-area:1/1).
   A sobreposição não é economia de markup: é a única forma de o painel ter
   altura de sobra para grudar. Se ele tivesse linha própria, o bloco que o
   contém seria essa linha — 38vh — e o painel sairia de cena no primeiro
   gesto, porque sticky não sai do bloco que o contém.
   Quem empurra o texto para baixo do painel é o padding-top dos passos; quem
   fica por cima é o z-index. O texto passa POR TRÁS da foto, que é o efeito. */
@media(max-width:900px){
  /* A FAIXA DE LEITURA DESCE. No desktop a zona de leitura é a tela inteira e
     o meio dela é o meio da tela — daí 45 (de 45% a 55%). Aqui o painel come
     os 34-38% de cima, e o que sobra para ler vai de ~38% a 100%, cujo meio
     é ~69%. Com a faixa no meio da TELA a foto trocava quando o texto já
     tinha passado pela zona de leitura: medido em 390x844, a troca acontecia
     com o parágrafo a ~100px acima de onde o olho está. Em 58 (de 58% a 68%)
     a foto vira enquanto se lê o bloco, que é o ponto do componente.
     Quem lê este número é o script, uma vez na carga e de novo a cada resize
     — a decisão de layout mora aqui, não lá. */
  .sync{--sync-band:58}

  .sync-head{margin-bottom:clamp(28px,5vh,44px)}

  .sync-grid{grid-template-columns:minmax(0,1fr);gap:0}

  .sync-steps{
    grid-area:1/1;
    padding-top:calc(var(--sync-top-m) + var(--sync-panel-m) + clamp(20px,5vw,32px));
  }
  /* Sem altura de tela por passo: no celular a rolagem já é longa, e 70vh por
     bloco somariam quatro telas de nada entre um texto e o outro. O respiro
     passa a ser padding, e o piso continua sendo mais alto que a faixa de 10%
     do observador — medido em 390x844: 84px de faixa contra ~330px de passo. */
  .sync-step{min-height:0;padding-block:clamp(26px,6vh,54px)}

  .sync-panel{
    grid-area:1/1;
    align-self:start;
    top:var(--sync-top-m);
    height:var(--sync-panel-m);
  }

  /* O fio vira barra no pé do painel, e o preenchimento vira scaleX. Sobre
     foto o trilho não pode ler --rule: em foto escura ele desaparece e em
     foto clara ele fica sujo. Aqui ele é uma tinta clara translúcida, que é o
     que funciona sobre qualquer imagem — mesma lógica da legenda, que lê
     --light e não --fg. */
  .sync-rail{
    grid-area:1/1;
    align-self:start;
    position:sticky;
    /* 10px acima da base da foto, e não encostado nela: os pontos têm 7px e
       ficam centrados no fio, então na base exata eles avançavam 2,5px sobre a
       primeira linha de texto que sai de baixo do painel — medido, cortavam os
       ascendentes. Recuados para dentro da imagem, a barra lê como o
       marcador de um player e o texto passa limpo por baixo. */
    top:calc(var(--sync-top-m) + var(--sync-panel-m) - 10px);
    width:auto;height:var(--sync-rail-w);
    z-index:3;
    background:color-mix(in srgb,var(--light) 22%,transparent);
  }
  .sync-rail-fill{transform:scaleX(var(--sync-p,0));transform-origin:left}
  .sync-dot{
    top:50%;
    left:calc((var(--sync-i,0) + .5) * var(--sync-slot));
    background:color-mix(in srgb,var(--light) 45%,transparent);
    box-shadow:0 0 0 3px rgba(var(--dark-rgb),.5);
  }
  .sync-dot.is-on{background:var(--accent)}

  /* 26px embaixo, e não 14: é o que abre folga entre a pílula e a barra que
     agora corre por dentro da imagem. */
  .sync-cap{padding:44px 16px 26px}
}

@media(max-width:560px){
  .sync{--sync-panel-m:34vh}
  .sync-text{font-size:16px;line-height:1.6}
  .sync-step-body{gap:10px}
}

script.js · 257 linhas · 11,2 KB

/* ==========================================================================
   ROLAGEM SINCRONIZADA — quem está sendo lido, e quanto já passou.

   O layout inteiro é CSS: as duas colunas, o painel pinado (position:sticky)
   e a fusão entre uma foto e a próxima. Este script faz duas coisas, e nada
   além:

     1. DECIDE QUAL PASSO ESTÁ SENDO LIDO. Um IntersectionObserver por passo,
        com a raiz encolhida a uma faixa de 10% da tela (rootMargin negativo
        em cima e embaixo). O passo que cobre mais dessa faixa é o ativo:
        troca a classe do texto, a foto do painel, a pílula da legenda e o
        ponto do trilho. Nenhum listener de rolagem para isto — o observador
        basta e não custa quadro. O único caso que ele não cobre sozinho é o
        salto que passa POR FORA da coluna de passos, e quem o tapa é o
        resolverPonta() lá embaixo.

     2. MEDE O PROGRESSO DA COLUNA para o preenchimento do trilho, essa sim
        uma conta por quadro, assinada no laço único do core (LIB.onScroll).
        Uma leitura de layout, uma escrita de custom property, e só quando o
        valor muda de verdade.

   POR QUE A COBERTURA E NÃO O intersectionRatio. O ratio é a fração do
   PASSO que está dentro da faixa, então um passo mais alto que o vizinho sai
   com ratio menor cobrindo exatamente a mesma faixa. O que interessa aqui é
   quanto da FAIXA cada passo ocupa — e isso é a altura do intersectionRect,
   que não depende do tamanho do alvo. Com passos de alturas diferentes (o
   caso do celular, onde a altura é o texto) o ratio escolheria o passo curto.

   TROCA DUPLA. Quando dois passos cruzam a faixa no mesmo quadro, o
   observador entrega os dois na mesma chamada. Por isso a cobertura é
   guardada num vetor e a decisão é tomada UMA vez, no fim, com o vetor
   inteiro na mão: não existe "o último a entrar ganha", e a foto nunca troca
   duas vezes no mesmo quadro.

   MOVIMENTO REDUZIDO: este script não tem ramo para a preferência, e isso é
   decisão, não esquecimento. Tudo que ele escreve é leitura de posição —
   qual passo está na faixa, quanto da coluna já passou. Nada anda sozinho:
   sem gesto da pessoa, nenhum valor daqui muda. O que a preferência desliga
   (o blur e a escala da troca de foto, o crescimento do ponto do trilho) é
   CSS, e está desligado lá, colado nas regras que ele apaga. A flag do core
   (LIB.reduce) segue disponível para quem quiser um ramo. O que não se deve
   fazer é congelar o trilho ou fazê-lo saltar de passo em passo: indicador
   de posição que não acompanha a rolagem não é menos movimento, é informação
   errada — e o salto seria, ele próprio, um movimento mais brusco que o
   deslizar que ele substituiria.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-sync]');
  if (!raizes.length) return;                        // guard silencioso

  /* O laço de rolagem do core é a fonte. O plano B existe para o componente
     colado sozinho, sem motion.js — mesmo contrato: um rAF, passive, e a
     primeira chamada logo de saída. */
  function assinarRolagem(fn) {
    if (window.LIB && typeof window.LIB.onScroll === 'function') {
      window.LIB.onScroll(fn);
      return;
    }
    var pedido = 0;
    function agendar() {
      if (pedido) return;
      pedido = requestAnimationFrame(function () { pedido = 0; fn(); });
    }
    addEventListener('scroll', agendar, { passive: true });
    addEventListener('resize', agendar, { passive: true });
    fn();
  }

  function iniciar(raiz) {
    var passos = [].slice.call(raiz.querySelectorAll('[data-sync-step]'));
    var fotos  = [].slice.call(raiz.querySelectorAll('[data-sync-img]'));
    /* Um passo só não sincroniza nada: sai sem erro e a dobra fica sendo o
       que o markup já mostra. */
    if (passos.length < 2 || !fotos.length) return;

    var pontos  = [].slice.call(raiz.querySelectorAll('[data-sync-dot]'));
    var legenda = raiz.querySelector('[data-sync-cap]');
    var n = passos.length;

    /* O título da legenda é LIDO do passo, nunca escrito duas vezes. O markup
       traz o do primeiro passo dentro da pílula só para a dobra ficar certa
       sem JavaScript; daqui em diante quem manda é o <h3>. */
    var titulos = [];
    for (var t = 0; t < n; t++) {
      var h = passos[t].querySelector('.sync-title');
      titulos.push(h ? h.textContent.replace(/\s+/g, ' ').trim() : '');
    }

    /* A fatia do trilho por passo vem da contagem real: acrescentar um quinto
       passo no markup não obriga ninguém a lembrar de mexer no CSS. O valor
       do CSS (--sync-slot:25%) é só o que vale antes disto rodar. */
    raiz.style.setProperty('--sync-slot', (100 / n) + '%');

    /* O ponto de partida é o que o markup marcou, não o índice zero — assim
       uma página pode abrir a dobra em qualquer passo. */
    var ativo = 0;
    for (var a = 0; a < n; a++) {
      if (passos[a].classList.contains('is-active')) ativo = a;
    }

    var cobertura = [];
    for (var c = 0; c < n; c++) cobertura[c] = 0;

    var dentro = false;      /* a dobra está na tela */
    var faixa = 45;          /* topo da faixa, em % da tela — vem do CSS */
    var io = null;
    var ultimo = '';         /* último --sync-p escrito */
    var pAtual = 0;          /* último progresso calculado, de 0 a 1 */

    /* ------------------------------------------------------------ troca */

    function ativar(i) {
      if (i < 0 || i >= n || i === ativo) return;
      passos[ativo].classList.remove('is-active');
      if (fotos[ativo])  fotos[ativo].classList.remove('is-on');
      if (pontos[ativo]) pontos[ativo].classList.remove('is-on');

      ativo = i;

      passos[i].classList.add('is-active');
      if (fotos[i])  fotos[i].classList.add('is-on');
      if (pontos[i]) pontos[i].classList.add('is-on');
      if (legenda && titulos[i]) legenda.textContent = titulos[i];
    }

    function aoCruzar(entradas) {
      for (var i = 0; i < entradas.length; i++) {
        var idx = passos.indexOf(entradas[i].target);
        if (idx < 0) continue;
        cobertura[idx] = entradas[i].isIntersecting
          ? entradas[i].intersectionRect.height
          : 0;
      }
      var melhor = -1, maior = 0;
      for (var j = 0; j < n; j++) {
        if (cobertura[j] > maior) { maior = cobertura[j]; melhor = j; }
      }
      /* Ninguém na faixa: não decide nada aqui. Quem resolve esse caso é o
         progresso(), com a geometria na mão — ver o bloco lá embaixo. */
      if (melhor >= 0) ativar(melhor); else resolverPonta();
    }

    function vazio() {
      for (var i = 0; i < n; i++) if (cobertura[i] > 0) return false;
      return true;
    }

    /* O BURACO QUE O OBSERVADOR SOZINHO NÃO TAPA. Os passos são contíguos:
       enquanto o ponteiro estiver dentro da coluna, sempre há um deles
       cobrindo a faixa. Fora da coluna não há nenhum, e o observador não tem
       o que reportar. Se a pessoa CHEGA nesse estado por um salto — âncora,
       arrasto da barra de rolagem, restauração de posição ao voltar — os
       passos do meio nunca cruzaram a faixa, o observador nunca disparou por
       eles, e o painel continuaria mostrando a foto de quando a página
       carregou. 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.

       Como o ponteiro só sai da coluna por cima ou por baixo, o progresso já
       está cravado em 0 ou em 1, e a conta devolve exatamente a ponta certa.

       É chamado dos DOIS lados — do observador e do quadro de rolagem —
       porque a ordem entre eles não é garantida: num salto, o quadro de
       rolagem pode rodar antes de o observador ter zerado a cobertura, e aí
       só a segunda chamada enxerga o estado certo. As duas convergem para o
       mesmo valor, então chamar duas vezes não custa nada além da conta. */
    function resolverPonta() {
      if (!vazio()) return;
      var i = Math.floor(pAtual * n);
      ativar(i > n - 1 ? n - 1 : i);
    }

    /* ------------------------------------------------------------ a faixa */

    /* --sync-band é um número puro no CSS, e MUDA na virada de layout: no
       celular o painel come o topo da tela e a zona de leitura desce. Ler do
       CSS em vez de cravar aqui é o que mantém a decisão de layout num lugar
       só — a media query. A faixa tem sempre 10% de altura. */
    function medirFaixa() {
      var v = parseFloat(getComputedStyle(raiz).getPropertyValue('--sync-band'));
      return (v >= 0 && v <= 90) ? v : 45;
    }

    function observar() {
      var b = medirFaixa();
      if (io && b === faixa) return;
      faixa = b;
      if (io) io.disconnect();
      for (var k = 0; k < n; k++) cobertura[k] = 0;
      io = new IntersectionObserver(aoCruzar, {
        rootMargin: '-' + faixa + '% 0px -' + (90 - faixa) + '% 0px',
        threshold: 0
      });
      for (var j = 0; j < n; j++) io.observe(passos[j]);
    }

    /* ------------------------------------------------------------ progresso */

    /* Do topo do primeiro passo ao fim do último, com o centro da faixa de
       leitura como ponteiro. O denominador é a coluna de passos, e não a
       seção: o cabeçalho e o respiro da dobra não são progresso.

       O ponteiro é o centro da FAIXA (faixa + 5), e não o centro da tela: é
       o que faz o preenchimento cruzar o ponto de um passo no mesmo instante
       em que aquele passo vira o ativo. Os dois indicadores concordam por
       construção. */
    function progresso() {
      if (!dentro) return;
      var primeiro = passos[0].getBoundingClientRect();
      var ultimoP  = passos[n - 1].getBoundingClientRect();
      var vao = ultimoP.bottom - primeiro.top;
      if (vao <= 0) return;

      var p = (innerHeight * (faixa + 5) / 100 - primeiro.top) / vao;
      p = p < 0 ? 0 : (p > 1 ? 1 : p);

      pAtual = p;
      resolverPonta();

      /* Quatro casas bastam para um trilho de duas mil e poucas linhas de
         altura, e a comparação evita reescrever a mesma string a cada quadro
         de uma rolagem parada. */
      var s = p.toFixed(4);
      if (s === ultimo) return;
      ultimo = s;
      raiz.style.setProperty('--sync-p', s);
    }

    /* ------------------------------------------------------------ portão */

    /* Fora da tela não há o que medir. O portão é o que faz a assinatura no
       laço do core custar uma comparação por quadro, e não duas leituras de
       layout. */
    var portao = new IntersectionObserver(function (entradas) {
      for (var i = 0; i < entradas.length; i++) dentro = entradas[i].isIntersecting;
      if (dentro) progresso();
    }, { threshold: 0 });

    /* Uma medida por quadro: getComputedStyle e getBoundingClientRect leem
       layout, e chamar os dois a cada evento de resize trava a janela. */
    var pedido = 0;
    addEventListener('resize', function () {
      if (pedido) return;
      pedido = requestAnimationFrame(function () {
        pedido = 0;
        observar();     /* a faixa muda de lugar quando o layout vira */
        progresso();
      });
    }, { passive: true });

    observar();
    portao.observe(raiz);
    assinarRolagem(progresso);
  }

  for (var r = 0; r < raizes.length; r++) iniciar(raizes[r]);
})();

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

Quando não usar


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


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


Gotchas


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.