← Biblioteca componentes/midia/faixa-horizontal-pinada/ficha.md CódigoTela cheia
midia/faixa-horizontal-pinada

Faixa horizontal pinada

🟢 estavel complexidade alta prefixo pin- scroll-horizontal secao-pinada sticky scrub faixa etapas metodo galeria imagem progresso contador sangria scroll-snap

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

<link rel="stylesheet" href="componentes/midia/faixa-horizontal-pinada/estilo.css">
<script src="componentes/midia/faixa-horizontal-pinada/script.js"></script>

markup.html · 131 linhas · 6,2 KB

<!-- ==========================================================================
     FAIXA HORIZONTAL PINADA — a dobra que atravessa
     Prefixo: pin-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. A conta do scrub é nossa, dentro do laço único do core.

     A SEÇÃO NÃO TEM .sec-pad E NÃO TEM overflow:hidden, e as duas ausências
     são deliberadas. O respiro: no modo pinado a dobra é uma tela inteira de
     ponta a ponta, e um padding vertical entraria na altura calculada e
     desalinharia o fim do sticky; no modo nativo ele volta por --pin-pad,
     que lê o mesmo --sec-gap do .sec-pad. O recorte: overflow diferente de
     visible num ancestral do sticky prende o palco a um scrollport que não
     rola, e ele deixa de grudar. Quem recorta é o .pin-stage, que é o
     próprio elemento que gruda.

     A ALTURA DA SEÇÃO é escrita pelo CSS a partir de --pin-len, que o script
     mede: uma tela mais o tanto que o trilho sobra da janela. Com isso, o
     trecho de rolagem em que o palco fica preso tem exatamente o
     comprimento do deslocamento horizontal, e 1px de scroll vale 1px de
     trilho. Sem script não há --pin-len: a seção volta à altura natural e a
     faixa rola sozinha, com encaixe, como uma galeria comum.

     CABEÇALHO E PÉ FLUTUAM sobre as fotos no modo pinado e voltam ao fluxo
     no nativo — a mesma regra serve aos dois, porque position vem de
     variável. Eles não colidem com os cards por construção, e não por
     tentativa: a largura do card tem teto de --pin-free * 1,6, e --pin-free
     é a tela menos as duas faixas reservadas.

     ENTRADA só nos dois primeiros cards. Do terceiro em diante o card nasce
     fora da tela e entra pela rolagem, que já é a entrada dele; um data-rv
     ali seria opacidade e blur disparando no meio do movimento.

     O TRILHO É FOCÁVEL (tabindex="0") por causa do modo nativo: sem isso,
     quem navega por teclado não passa do segundo card numa faixa que rola.
     No modo pinado o mesmo foco não atrapalha — as setas rolam a página, que
     é o que move a faixa. Os cards não são links de propósito (ver a ficha):
     um <a> aqui exigiria que o Tab rolasse a página até o ponto exato em que
     aquele card está no centro.
     ========================================================================== -->

<section class="sec-deep pin" id="metodo" data-pin>
  <div class="pin-stage">

    <div class="pin-head">
      <span class="eyebrow" data-rv>Método</span>
      <h2 class="sec-title ttl">O caminho de um <em>patrimônio</em></h2>
    </div>

    <!-- Lista de verdade: o leitor de tela anuncia "6 itens" antes do
         primeiro card, que é justamente a informação que uma faixa
         horizontal esconde de quem não vê a fila continuar fora da tela. -->
    <ul class="pin-track" data-pin-track tabindex="0" aria-label="As seis etapas do método">

      <li class="pin-item pin-item--wide" data-rv>
        <figure class="pin-card">
          <img class="pin-img" src="../../../showroom/demo/ph-wide-2.webp" alt="Homem observando a cidade pela janela" decoding="async">
          <span class="pin-num">01</span>
          <figcaption class="pin-cap">
            <h3 class="pin-name">Diagnóstico</h3>
            <p class="pin-line">Onde o patrimônio está hoje, em uma folha só</p>
          </figcaption>
        </figure>
      </li>

      <li class="pin-item" data-rv data-d="1">
        <figure class="pin-card">
          <img class="pin-img" src="../../../showroom/demo/ph-wide-4.webp" alt="Sala de reunião vazia com vista para a cidade" decoding="async">
          <span class="pin-num">02</span>
          <figcaption class="pin-cap">
            <h3 class="pin-name">Política de investimentos</h3>
            <p class="pin-line">O documento que orienta cada decisão</p>
          </figcaption>
        </figure>
      </li>

      <li class="pin-item pin-item--wide">
        <figure class="pin-card">
          <img class="pin-img" src="../../../showroom/demo/ph-wide-3.webp" alt="Veleiro em mar aberto" decoding="async">
          <span class="pin-num">03</span>
          <figcaption class="pin-cap">
            <h3 class="pin-name">Alocação</h3>
            <p class="pin-line">Cada ativo na instituição que paga melhor por ele</p>
          </figcaption>
        </figure>
      </li>

      <li class="pin-item">
        <figure class="pin-card">
          <img class="pin-img" src="../../../showroom/demo/ph-wide-1.webp" alt="Casal em um vinhedo ao entardecer" loading="lazy" decoding="async">
          <span class="pin-num">04</span>
          <figcaption class="pin-cap">
            <h3 class="pin-name">Proteção</h3>
            <p class="pin-line">Seguro, reserva e holding, na ordem certa</p>
          </figcaption>
        </figure>
      </li>

      <li class="pin-item pin-item--wide">
        <figure class="pin-card">
          <img class="pin-img" src="../../../showroom/demo/ph-wide-5.webp" alt="Casal assistindo a uma partida de tênis em quadra de saibro" loading="lazy" decoding="async">
          <span class="pin-num">05</span>
          <figcaption class="pin-cap">
            <h3 class="pin-name">Sucessão</h3>
            <p class="pin-line">A conversa que a família adiava</p>
          </figcaption>
        </figure>
      </li>

      <li class="pin-item">
        <figure class="pin-card">
          <img class="pin-img" src="../../../showroom/demo/ph-wide-4.webp" alt="Sala de reunião vazia com vista para a cidade" loading="lazy" decoding="async">
          <span class="pin-num">06</span>
          <figcaption class="pin-cap">
            <h3 class="pin-name">Acompanhamento</h3>
            <p class="pin-line">Comitê trimestral e relatório no dia 5</p>
          </figcaption>
        </figure>
      </li>

    </ul>

    <!-- aria-atomic para o leitor de tela anunciar "03 de 06" inteiro, e não
         só o número que mudou. Sem ele a pessoa ouve "três" sem saber de
         quantos. -->
    <div class="pin-foot">
      <div class="pin-bar"><span class="pin-bar-fill"></span></div>
      <p class="pin-count" aria-live="polite" aria-atomic="true"><span class="pin-count-now" data-pin-count>01</span> / <span class="pin-count-all">06</span></p>
    </div>

  </div>
</section>

estilo.css · 345 linhas · 15,6 KB

/* ==========================================================================
   FAIXA HORIZONTAL PINADA — prefixo pin-
   A seção prende a tela e a fila de imagens atravessa da direita para a
   esquerda: 1px de rolagem vertical = 1px de deslocamento horizontal.

   Tokens usados:
     --pad --max          conta do recuo que alinha o trilho à coluna
     --sec-gap            respiro vertical — só no modo nativo (ver abaixo)
     --display            tipografia de título, número e contador
     --accent             preenchimento da barra de progresso
     --dark-rgb           véus em gradiente sobre a foto
     --light              texto sobre a foto (ilha escura)
     --radius-media       canto do card
     --rule-2             trilho da barra de progresso
     --fg --fg-muted      título do cabeçalho e contador (fora da foto)

   ---------------------------------------------------------------------
   DOIS MODOS, E O PADRÃO É O SEGURO.

   modo PINADO   .pin.is-pinned — o palco gruda (position:sticky) e o script
                 escreve --pin-x e --pin-p a cada quadro.
   modo NATIVO   .pin sozinho — faixa comum de scroll-snap horizontal, o
                 mesmo mecanismo do midia/galeria-scroll-horizontal.

   O modo pinado é OPT-IN por classe, e não o contrário, porque ele depende
   de JavaScript para existir: sem o script não há --pin-len, e uma seção de
   uma tela com overflow:hidden esconderia quatro dos seis cards para
   sempre. Sendo o nativo o padrão, JS bloqueado, quebrado ou ainda não
   executado entrega uma faixa que rola — não uma dobra truncada.

   A troca entre os dois é toda por VARIÁVEL (--pin-pos, --pin-of, --pin-tx…),
   não por regra duplicada: cada modo mora num lugar só, e a media query do
   fim precisa de uma regra apenas para desligar o pinado no celular.

   NÃO HÁ backdrop-filter nos véus do cabeçalho e do pé: eles cobrem a
   largura inteira do palco por cima de um trilho que se move todo quadro, e
   desfocar essa faixa manda o navegador re-rasterizar a tela a cada quadro.
   Gradiente sozinho já é a sua própria máscara. O véu da legenda, esse sim,
   é o do §6 (gradiente + blur + máscara), porque é caixa pequena e o texto
   está dentro da foto.
   ========================================================================== */

.pin{
  /* SANGRIA SEM MARGEM NEGATIVA (contrato §4).
     Não existe .wrap nesta dobra: o trilho precisa atravessar a página
     inteira. Esta variável refaz a conta do .wrap — metade da sobra mais o
     respiro — e alinha o PRIMEIRO card à coluna; o mesmo valor no
     padding-inline-end alinha o ÚLTIMO à borda direita dela.
       tela estreita  ->  a coluna já encosta nas bordas, vale --pad
       tela larga     ->  (100% - --max) / 2 + --pad
     O 100% é a largura do palco, que é a largura da seção. */
  --pin-inset:max(var(--pad),calc((100% - var(--max)) / 2 + var(--pad)));

  --pin-gap:clamp(18px,2.4vw,34px);    /* vão entre um card e o próximo */

  /* Altura do palco. svh e não vh: em tablet com barra de endereço que some,
     vh muda de valor no meio do gesto e o sticky salta; svh é a menor das
     alturas e não se mexe. dvh seria pior ainda — muda a cada quadro. */
  --pin-vh:100vh;

  /* Faixa reservada em cima e embaixo para o cabeçalho e o pé. É um
     CONTRATO com o tamanho do card: o card nunca passa de --pin-free de
     altura, então nunca invade a faixa, em nenhuma altura de tela.
     O piso de 140px é a altura MEDIDA do cabeçalho em 1440x900 (136px: olho,
     título de uma linha e o respiro de 30px em cima e embaixo) mais folga.
     Se o título ganhar uma segunda linha ele passa de 172px e come essa
     folga — nesse caso suba o piso junto, ou encurte o título. */
  --pin-band:clamp(140px,15vh,168px);
  --pin-free:calc(var(--pin-vh) - var(--pin-band) * 2);

  /* Largura dos cards, alternada para dar ritmo. O segundo termo do min()
     é o que garante a folga acima: com caixa 16:10, uma largura de
     --pin-free * 1.6 tem exatamente --pin-free de altura. Em tela baixa
     manda o teto de altura; em tela larga e alta, o vw. */
  --pin-w-wide:min(58vw,calc(var(--pin-free) * 1.6));
  --pin-w-small:min(36vw,calc(var(--pin-free) * 1.6));

  /* Suavização do trilho, 0 a 1: fração do que falta percorrida a cada
     quadro de 60Hz (a conta é por tempo, então 120Hz anda igual).
     .14 é perseguição visível sem borracha; 1 gruda no scroll. */
  --pin-lerp:.14;

  /* ---- modo NATIVO (padrão) ---- */
  --pin-pos:static;         /* palco */
  --pin-h:auto;             /* palco */
  --pin-of:visible;         /* palco */
  --pin-sec-h:auto;         /* seção */
  --pin-pad:var(--sec-gap); /* respiro da seção, do mesmo token do .sec-pad */
  --pin-abs:static;         /* cabeçalho e pé */
  --pin-track-h:auto;
  --pin-track-ofx:auto;     /* o trilho é quem rola */
  --pin-track-ofy:hidden;
  --pin-snap:x mandatory;
  --pin-tx:none;
  --pin-veil-top:none;
  --pin-veil-bottom:none;
  --pin-head-pad:0 0 clamp(26px,4vh,44px);
  --pin-foot-pad:clamp(26px,4vh,44px) 0 0;

  position:relative;
  height:var(--pin-sec-h);
  padding-block:var(--pin-pad);
}

/* Onde svh existe (todo motor desde 2022), ele vale. Fora do @supports o
   var() não teria como cair de volta para vh: custom property aceita
   qualquer token, e um --pin-vh inválido derrubaria a altura para auto. */
@supports (height:100svh){
  .pin{--pin-vh:100svh}
}

/* ---- modo PINADO ----
   Ligado pelo script quando a tela é larga e ninguém pediu menos movimento.
   --pin-len (o quanto o trilho sobra da tela) e --pin-x (o quanto ele já
   andou) são escritos pelo script na raiz. */
.pin.is-pinned{
  --pin-pos:sticky;
  --pin-h:var(--pin-vh);
  --pin-of:hidden;
  --pin-sec-h:calc(var(--pin-vh) + var(--pin-len,0px));
  --pin-pad:0;
  --pin-abs:absolute;
  --pin-track-h:100%;
  /* Os DOIS eixos em visible, e não só o x: quando um dos dois é visible e o
     outro não, o navegador promove o visible a auto e o trilho vira
     contêiner de rolagem por baixo do pano — com scrollLeft próprio brigando
     com o transform, e um pulo lateral toda vez que o foco entra nele.
     Medido: com overflow-y:hidden, o computado do x saía "auto". */
  --pin-track-ofx:visible;
  --pin-track-ofy:visible;
  --pin-snap:none;
  --pin-tx:translate3d(calc(var(--pin-x,0px) * -1),0,0);
  --pin-veil-top:linear-gradient(to bottom,
    rgba(var(--dark-rgb),.86) 0%,rgba(var(--dark-rgb),.5) 48%,transparent 100%);
  --pin-veil-bottom:linear-gradient(to top,
    rgba(var(--dark-rgb),.9) 0%,rgba(var(--dark-rgb),.52) 52%,transparent 100%);
  --pin-head-pad:clamp(22px,3.4vh,38px) 0;
  --pin-foot-pad:clamp(22px,3.4vh,38px) 0;
}

/* A camada que se move ganha camada própria só enquanto a dobra está por
   perto — o script põe e tira .is-near por IntersectionObserver.
   O trilho pinado mede ~4570 x 522 px em 1440: promovido para sempre, isso
   é textura de GPU parada na memória a página inteira. Enquanto está aceso,
   porém, ele se paga: o cabeçalho e o pé ficam em outra camada e param de
   ser repintados a cada quadro. */
.pin.is-near .pin-track{will-change:transform}

/* ---------------------------------------------------------------- palco */
/* O recorte mora AQUI, no elemento que gruda — nunca na seção. overflow
   diferente de visible num ANCESTRAL do sticky o prende a um scrollport que
   não rola, e o palco deixa de grudar. É por isso que .pin não tem
   overflow:hidden, por mais que o trilho vaze dela. */
.pin-stage{
  position:var(--pin-pos);top:0;
  height:var(--pin-h);
  overflow:var(--pin-of);
}

/* ---------------------------------------------------------------- cabeçalho */
/* Absoluto no pinado (flutua sobre as fotos, com véu), em fluxo no nativo.
   top/left/right e z-index são ignorados sozinhos quando --pin-abs é static:
   uma regra só serve aos dois modos.
   A faixa cobre a foto de ponta a ponta: sem pointer-events:none ela seria
   uma tampa invisível sobre o card em toda a largura do palco. O texto
   dentro dela volta a receber ponteiro, senão deixaria de ser selecionável. */
.pin-head{
  position:var(--pin-abs);top:0;left:0;right:0;z-index:2;
  padding:var(--pin-head-pad);
  padding-inline:var(--pin-inset);
  background:var(--pin-veil-top);
  pointer-events:none;
}
.pin-head > *{pointer-events:auto}
/* 22ch é a largura do h2.sec-title do core, e nela um título de 26 caracteres
   quebra em duas linhas — o cabeçalho passa de 172px de altura e come a
   folga do card em tela baixa. Aqui ele tem a coluna inteira e cabe numa
   linha só até 32 caracteres. */
.pin-head .sec-title{
  font-size:clamp(20px,2.2vw,30px);
  max-width:34ch;margin-top:10px;
}

/* ---------------------------------------------------------------- trilho */
.pin-track{
  display:flex;align-items:center;
  gap:var(--pin-gap);
  height:var(--pin-track-h);
  margin:0;list-style:none;
  padding-inline:var(--pin-inset);
  transform:var(--pin-tx);

  /* Daqui para baixo só vale no modo nativo, onde o trilho é contêiner de
     rolagem. No pinado o overflow é visible e o navegador ignora tudo. */
  overflow-x:var(--pin-track-ofx);overflow-y:var(--pin-track-ofy);
  scroll-snap-type:var(--pin-snap);
  scroll-padding-inline:var(--pin-inset);
  overscroll-behavior-x:contain;
  -webkit-overflow-scrolling:touch;
  scrollbar-width:none;
  -ms-overflow-style:none;
}
.pin-track::-webkit-scrollbar{width:0;height:0;display:none}
/* O trilho é focável (tabindex no markup) para que as setas do teclado
   rolem a faixa no modo nativo — no pinado elas rolam a página, que é o que
   move a faixa. O anel do core sai 3px para fora e seria comido pelo
   overflow:hidden do palco; para dentro ele aparece nos dois modos. */
.pin-track:focus-visible{outline-offset:-4px}

.pin-item{flex:0 0 auto;width:var(--pin-w-small);scroll-snap-align:start}
/* Mesma especificidade da regra acima: quem decide é a ordem no arquivo.
   Não inverta. */
.pin-item--wide{width:var(--pin-w-wide)}

/* ---------------------------------------------------------------- card */
/* A caixa 16:10 existe ANTES de a foto chegar: é ela que impede o trilho de
   mudar de largura quando as imagens carregam, e é por isso que o --pin-len
   medido no primeiro quadro continua valendo depois. */
.pin-card{
  position:relative;margin:0;
  aspect-ratio:16/10;
  overflow:hidden;
  border-radius:var(--radius-media);
  background:rgba(var(--dark-rgb),.5);
}
.pin-img{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  /* Cinza parcial mantém a fila coesa sem a foto virar outra imagem. */
  filter:grayscale(.22);
}

/* Número grande no canto, sobre a foto: lê --light, como tudo que está
   dentro da imagem. A sombra é o que garante leitura sobre céu claro sem
   pedir mais uma camada de véu. */
.pin-num{
  position:absolute;top:0;left:0;z-index:2;
  padding:clamp(14px,1.6vw,26px) clamp(16px,1.8vw,28px);
  font-family:var(--display);font-weight:700;line-height:1;
  font-size:clamp(30px,3vw,42px);letter-spacing:-.03em;
  color:color-mix(in srgb,var(--light) 74%,transparent);
  text-shadow:0 2px 30px rgba(var(--dark-rgb),.55);
}
.pin-item--wide .pin-num{font-size:clamp(38px,4.2vw,58px)}

/* Legenda DENTRO da foto (contrato §6): gradiente, desfoque e máscara. Aqui
   o backdrop-filter se paga — é caixa pequena, e sem ele o texto sobre uma
   foto clara pediria um gradiente tão pesado que apagaria a imagem. */
.pin-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  display:flex;flex-direction:column;gap:8px;
  padding:clamp(44px,6vw,86px) clamp(16px,1.8vw,28px) clamp(16px,1.8vw,26px);
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.92) 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%);
}
.pin-name{
  font-family:var(--display);font-weight:700;
  font-size:clamp(17px,1.5vw,21px);line-height:1.2;letter-spacing:-.022em;
  color:var(--light);
}
.pin-line{
  font-size:clamp(13px,1.05vw,15px);line-height:1.45;
  color:color-mix(in srgb,var(--light) 78%,transparent);
  max-width:34ch;
  /* Duas linhas equilibradas em vez de uma cheia e uma com uma palavra só:
     "…relatório no dia / 5" era o que saía sem isto. */
  text-wrap:balance;
}
.pin-item--wide .pin-name{font-size:clamp(20px,2.1vw,28px)}
.pin-item--wide .pin-line{font-size:clamp(14px,1.2vw,17px)}

/* ---------------------------------------------------------------- pé */
.pin-foot{
  position:var(--pin-abs);bottom:0;left:0;right:0;z-index:2;
  display:flex;align-items:center;gap:clamp(16px,2vw,28px);
  padding:var(--pin-foot-pad);
  padding-inline:var(--pin-inset);
  background:var(--pin-veil-bottom);
  pointer-events:none;
}
.pin-foot > *{pointer-events:auto}
.pin-bar{
  flex:1 1 auto;position:relative;height:2px;overflow:hidden;
  background:var(--rule-2);
}
/* scaleX e não width: largura anima em layout, escala anima no compositor —
   e esta barra é redesenhada em todo quadro de rolagem. */
.pin-bar-fill{
  position:absolute;inset:0;
  background:var(--accent);
  transform-origin:left center;
  transform:scaleX(var(--pin-p,0));
}
.pin-count{
  flex:0 0 auto;
  font-family:var(--display);font-weight:700;
  font-size:12px;letter-spacing:.12em;
  /* Sem tabular-nums o contador muda de largura entre "01" e "06" e a barra
     pulsa junto a cada troca. */
  font-variant-numeric:tabular-nums;
  color:var(--fg-muted);
}
.pin-count-now{color:var(--fg)}

/* ---------------------------------------------------------------- movimento reduzido */
/* Não há @media(prefers-reduced-motion) aqui, e é de propósito: quem pediu
   menos movimento simplesmente não entra no modo pinado, e quem decide isso
   é o script — que também respeita o ?motion=on do core. Uma media query
   neste arquivo ignoraria o parâmetro e brigaria com ele, deixando o script
   escrevendo transform num trilho que o CSS já devolveu ao modo nativo.
   Fora o modo pinado, nada neste componente anda sozinho: a faixa nativa só
   se move quando a pessoa a empurra. */

/* ---------------------------------------------------------------- mobile */
/* Abaixo de 760px o modo pinado é desligado no CSS, mesmo que a classe
   esteja lá: prender a rolagem vertical a um deslocamento horizontal
   desorienta no celular e briga com o gesto de voltar do navegador. Isto é
   rede de segurança — o script chega à mesma conclusão pela mesma media
   query —, e é o que protege quem colar a classe à mão no HTML.
   Uma regra só, porque o modo inteiro é variável. */
@media(max-width:760px){
  .pin.is-pinned{
    --pin-pos:static;--pin-h:auto;--pin-of:visible;
    --pin-sec-h:auto;--pin-pad:var(--sec-gap);--pin-abs:static;
    --pin-track-h:auto;--pin-track-ofx:auto;--pin-track-ofy:hidden;
    --pin-snap:x mandatory;
    --pin-tx:none;--pin-veil-top:none;--pin-veil-bottom:none;
    --pin-head-pad:0 0 clamp(26px,4vh,44px);
    --pin-foot-pad:clamp(26px,4vh,44px) 0 0;
  }
  /* Card único na tela, com um pedaço do próximo à mostra: 36vw seriam
     140px de foto num celular. A alternância de larguras só faz sentido
     onde cabe mais de um card. */
  .pin{--pin-w-wide:min(84vw,360px);--pin-w-small:min(84vw,360px)}
  /* 16:10 num card de 328px deixa 195px de foto, e a legenda come metade
     dela. Em tela estreita a caixa fica mais alta para sobrar imagem — o
     recorte muda, o assunto continua no meio. */
  .pin-card{aspect-ratio:4/3}
  .pin-num{font-size:26px;padding:12px 14px}
  /* Respiro para o anel de foco do trilho não ser cortado pelo overflow. */
  .pin-track{padding-block:6px 10px}
  .pin-cap{padding:40px 14px 14px;gap:6px}
}

script.js · 320 linhas · 12,9 KB

/* ==========================================================================
   FAIXA HORIZONTAL PINADA — o scrub do trilho.

   O empilhamento visual, o recorte e os dois modos são CSS. Este script faz
   quatro coisas, e nenhuma delas é animar por conta própria:

     1. MEDE o quanto o trilho sobra da tela e escreve --pin-len na raiz. É
        esse número que dá altura à seção — uma tela mais o comprimento que
        falta percorrer —, e é ele que faz 1px de rolagem vertical valer 1px
        de deslocamento horizontal;
     2. LÊ o progresso do trecho em que o palco fica preso e escreve --pin-x
        (o quanto o trilho já andou) e --pin-p (0 a 1, a barra);
     3. PERSEGUE o alvo com aproximação exponencial (--pin-lerp), com teto de
        atraso que fecha em zero nas duas pontas — no fim do trecho preso o
        trilho tem de estar EXATO, senão o palco solta com ele ainda andando
        e a dobra inteira parece escorregar;
     4. ESCOLHE o modo: pinado só em tela larga e sem "reduzir movimento".
        Abaixo de 760px e com movimento reduzido a dobra é uma faixa comum de
        scroll-snap, e aí o script não move nada — só acompanha o scrollLeft
        do trilho para a barra e o contador continuarem valendo.

   UM rAF SÓ, e ele dorme. O laço do core (LIB.onScroll) dispara enquanto a
   pessoa rola; quando ela para, o laço de casa termina a aproximação e se
   desliga sozinho a menos de meio pixel do alvo. Fora da tela, o
   IntersectionObserver desliga tudo e tira o will-change do trilho.

   MOVIMENTO REDUZIDO: sem modo pinado, ponto. Prender a rolagem da página a
   um deslocamento lateral é exatamente o tipo de movimento imposto que a
   preferência pede para evitar. O gesto que a pessoa faz — arrastar a faixa
   nativa — continua inteiro.
   ========================================================================== */

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

  /* A flag do core é a fonte. O fallback existe para o componente colado
     sozinho, sem motion.js — e repete a regra do ?motion=on. */
  function reduzido() {
    if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  /* Preso ao scroll da PÁGINA: assina o laço único do core. O fallback
     repete a semântica dele — chamada imediata e um rAF por quadro, nunca
     conta dentro do listener. */
  function assinar(fn) {
    if (window.LIB && window.LIB.onScroll) return window.LIB.onScroll(fn);
    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 palco  = raiz.querySelector('.pin-stage');
    var trilho = raiz.querySelector('[data-pin-track]');
    if (!palco || !trilho) return;

    var itens = [].slice.call(trilho.children);
    if (itens.length < 2) return;

    var conta = raiz.querySelector('[data-pin-count]');

    /* A MESMA media query do fim do estilo.css. Duas fontes para o mesmo
       limiar seriam duas chances de divergir: aqui o navegador responde
       pela regra que o CSS já aplicou. */
    var estreito = matchMedia('(max-width:760px)');

    var pinado  = false;   /* modo atual */
    var len     = 0;       /* px que o trilho sobra da tela */
    var curso   = 0;       /* px de rolagem em que o palco fica preso */
    var alvo    = 0;       /* deslocamento que o trilho deveria ter agora */
    var atual   = 0;       /* deslocamento que ele tem */
    var prog    = 0;       /* 0 a 1 */
    var lerp    = .14;
    var centros = [];      /* centro de cada card, medido sem o transform */
    var meio    = 0;       /* metade da largura visível */
    var indice  = -1;
    var raf = 0, antes = 0, medindo = 0;
    var perto = true;
    var larg = 0, alt = 0; /* últimas medidas da janela */

    function nativo() { return estreito.matches || reduzido(); }

    /* ---------------------------------------------------------- medidas */
    /* Tudo aqui é leitura de layout, e só roda no rAF do laço — nunca dentro
       de um listener. */

    function medir() {
      larg = innerWidth; alt = innerHeight;

      lerp = parseFloat(getComputedStyle(raiz).getPropertyValue('--pin-lerp'));
      if (!(lerp > 0)) lerp = 0;          /* 0 = sem suavização, posição direta */
      if (lerp > 1) lerp = 1;

      meio = trilho.clientWidth / 2;
      centros.length = 0;
      for (var i = 0; i < itens.length; i++) {
        /* offsetLeft não enxerga o transform do pai — é exatamente o que se
           quer: a posição do card na fila, não na tela. Medido uma vez, o
           contador não precisa de nenhum getBoundingClientRect por quadro. */
        centros.push(itens[i].offsetLeft + itens[i].offsetWidth / 2);
      }

      if (!pinado) {
        /* No modo nativo quem sabe o comprimento é o próprio contêiner de
           rolagem, e aí scrollWidth vale: com overflow:auto o motor conta o
           padding do fim. */
        len = trilho.scrollWidth - trilho.clientWidth;
        if (len < 0) len = 0;
        curso = 0;
        return;
      }

      /* COMPRIMENTO DO TRILHO. Não é scrollWidth: num flex container com
         overflow visible o padding-inline-end não entra nessa conta em
         nenhum motor, e o último card terminaria --pin-inset cedo demais —
         que é justamente o salto no fim do sticky. A borda direita do
         último card mais o recuo é a medida honesta, e ela é imune ao
         transform porque as duas caixas andam juntas. */
      var recuo = parseFloat(getComputedStyle(trilho).paddingRight) || 0;
      var esq   = trilho.getBoundingClientRect().left;
      var dir   = itens[itens.length - 1].getBoundingClientRect().right;
      len = Math.round(dir - esq + recuo - trilho.clientWidth);
      if (len < 0) len = 0;
      raiz.style.setProperty('--pin-len', len + 'px');

      /* Escrita seguida de leitura, de propósito: a altura nova da seção é
         justamente o que se quer medir. Não há círculo — mudar a altura da
         seção não muda o topo dela, e não muda a largura do trilho. */
      curso = raiz.offsetHeight - palco.offsetHeight;
      if (curso < 0) curso = 0;
    }

    function pedirMedida() {
      if (medindo) return;
      medindo = requestAnimationFrame(function () {
        medindo = 0;
        medir();
        medirAlvo();
        atual = alvo;
        aplicar();
      });
    }

    /* ---------------------------------------------------------- alvo */

    /* Onde o trilho DEVERIA estar agora. Os dois modos respondem aqui, e não
       cada um no seu canto: qualquer coisa que peça uma remedida (imagem que
       chega, resize, a dobra voltando à tela) chama esta função sem precisar
       saber em que modo está. Foi o que faltou uma vez: no modo nativo o
       alvo ficava em zero, e o load de uma foto lazy zerava o trilho junto. */
    function medirAlvo() {
      if (!pinado) {
        atual = alvo = trilho.scrollLeft;
        prog = len > 0 ? atual / len : 0;
        return;
      }
      var topo = raiz.getBoundingClientRect().top;
      var p = curso > 0 ? -topo / curso : 0;
      prog = p < 0 ? 0 : p > 1 ? 1 : p;
      alvo = prog * len;
    }

    /* ---------------------------------------------------------- escrita */

    function aplicar() {
      if (pinado) raiz.style.setProperty('--pin-x', atual.toFixed(1) + 'px');
      /* A barra segue o TRILHO (atual/len), não a rolagem (prog). São coisas
         diferentes enquanto a suavização persegue o alvo, e uma barra à
         frente das imagens denuncia o atraso em vez de escondê-lo. */
      raiz.style.setProperty('--pin-p', (len > 0 ? atual / len : 0).toFixed(4));

      var alvoC = atual + meio, melhor = -1, achado = 0, d;
      for (var i = 0; i < centros.length; i++) {
        d = centros[i] - alvoC; if (d < 0) d = -d;
        if (melhor < 0 || d < melhor) { melhor = d; achado = i; }
      }
      /* Só escreve quando muda: o contador é aria-live, e reescrever o mesmo
         número a 60 vezes por segundo faria o leitor de tela falar sem parar. */
      if (achado !== indice) {
        indice = achado;
        if (conta) conta.textContent = (achado < 9 ? '0' : '') + (achado + 1);
      }
    }

    /* ---------------------------------------------------------- laço */

    function passo(agora) {
      var dt = antes ? agora - antes : 16;
      if (antes && dt < 1) return;      /* já andamos neste quadro */
      if (dt > 50) dt = 50;             /* volta de aba dormindo não salta */
      antes = agora;

      /* Aproximação exponencial por TEMPO: a mesma perseguição em 60Hz e em
         120Hz. lerp 0 (ou 1) dispensa a conta e gruda no scroll. */
      var k = (lerp <= 0 || lerp >= 1) ? 1
            : 1 - Math.pow(1 - lerp, dt / 16.667);
      atual += (alvo - atual) * k;

      /* TETO DE ATRASO QUE FECHA NAS PONTAS. No meio do caminho o atraso é o
         efeito; nas pontas ele é defeito — o palco solta com o trilho ainda
         escorregando. A folga até a ponta mais próxima é o próprio teto, e
         em alvo=0 e alvo=len ela vale zero: posição exata, sem exceção. */
      var folga = alvo < len - alvo ? alvo : len - alvo;
      var teto = folga < 240 ? folga : 240;
      if (teto < 0) teto = 0;
      if (atual < alvo - teto) atual = alvo - teto;
      else if (atual > alvo + teto) atual = alvo + teto;

      if (Math.abs(alvo - atual) < .5) atual = alvo;
      aplicar();
    }

    function laco(t) {
      raf = 0;
      passo(t);
      if (Math.abs(alvo - atual) > .5) raf = requestAnimationFrame(laco);
      else antes = 0;                   /* dorme */
    }

    function acordar() {
      if (raf || Math.abs(alvo - atual) <= .5) return;
      raf = requestAnimationFrame(laco);
    }

    function dormir() {
      if (raf) { cancelAnimationFrame(raf); raf = 0; }
      antes = 0;
    }

    /* ---------------------------------------------------------- modo */

    function modo() {
      var quer = !nativo();
      if (quer === pinado) return false;
      pinado = quer;
      raiz.classList.toggle('is-pinned', pinado);
      if (!pinado) {
        dormir();
        raiz.style.removeProperty('--pin-len');
        raiz.style.removeProperty('--pin-x');
        atual = alvo = 0;
      }
      return true;
    }

    /* ---------------------------------------------------------- nativo */
    /* Scroll de ELEMENTO pede listener próprio: o laço do core observa a
       página, não um trilho com overflow. A barra e o contador continuam
       valendo, e nada aqui move o trilho — quem move é o dedo. */

    var pedidoNat = 0;
    trilho.addEventListener('scroll', function () {
      if (pinado || pedidoNat) return;
      pedidoNat = requestAnimationFrame(function () {
        pedidoNat = 0;
        medirAlvo();
        aplicar();
      });
    }, { passive: true });

    /* ---------------------------------------------------------- visibilidade */
    /* Fora da tela não há o que calcular, e o will-change do trilho (uma
       textura de vários milhares de pixels de largura) não precisa ficar
       ocupando memória de GPU a página inteira. */

    if (window.IntersectionObserver) {
      new IntersectionObserver(function (es) {
        for (var i = 0; i < es.length; i++) {
          perto = es[i].isIntersecting;
          raiz.classList.toggle('is-near', perto);
          if (perto) { medirAlvo(); atual = alvo; aplicar(); }
          else dormir();
        }
      }, { rootMargin: '300px 0px' }).observe(raiz);
    } else {
      raiz.classList.add('is-near');
    }

    /* ---------------------------------------------------------- imagens */
    /* A largura do card não depende da foto — quem reserva a caixa é o
       aspect-ratio do CSS, e é por isso que o trilho não muda de tamanho
       quando as imagens chegam. Isto aqui é cinto e suspensório: se um dia
       uma variação deixar a largura seguir a imagem, a medida se refaz
       sozinha em vez de ficar errada em silêncio. */
    var fotos = trilho.getElementsByTagName('img');
    for (var f = 0; f < fotos.length; f++) {
      if (fotos[f].complete) continue;
      fotos[f].addEventListener('load', pedirMedida);
    }

    /* ---------------------------------------------------------- partida */

    modo();
    medir();

    assinar(function () {
      if (larg !== innerWidth || alt !== innerHeight) { modo(); medir(); }
      if (!pinado || !perto) return;
      medirAlvo();
      passo(performance.now());
      acordar();
    });

    /* A página pode nascer já dentro da dobra (recarga no meio, âncora
       #metodo): a primeira posição é assumida, não perseguida. */
    medirAlvo();
    atual = alvo;
    aplicar();
  }

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

A seção prende a tela e a página continua rolando: enquanto a rolagem vertical desce, uma fila de fotos grandes e pequenas atravessa o palco da direita para a esquerda, um pixel para cada pixel. O cabeçalho — olho e título curto — fica parado no canto de cima, flutuando sobre as imagens; no pé, uma barra de progresso e um contador 03 / 06 dizem onde a pessoa está. Cada card carrega o número da etapa em corpo grande no canto e, dentro da própria imagem, o nome e uma linha. Quando o último card encosta na borda direita da coluna, o palco solta e a página segue.

O problema editorial é a sequência longa que não cabe empilhada. Seis etapas de um método viram, numa lista vertical, seis telas de rolagem em que cada item chega sozinho e o conjunto nunca é visto; viram, numa grade, seis cards pequenos em que a ordem some. Aqui a ordem é o próprio eixo do movimento — o que vem depois está literalmente à direita —, e a alternância de larguras dá à fila um ritmo de exposição, não de tabela. É o padrão que sites premiados usam para linha do tempo, método e portfólio, e que aqui roda sem GSAP: a conta é do componente, dentro do laço único do core.


Quando usar

Quando não usar

Contra os vizinhos

Precisa de Use
Etapas com título e parágrafo, sobre uma foto pinada listas/baralho-sticky
Vitrine de cases com destino próprio, sem prender a página midia/galeria-scroll-horizontal
Fila de pessoas que anda sozinha e responde ao arrasto midia/carrossel-perfis
Três a quatro blocos de imagem com número e frase, parados conteudo/pilares-texto-na-foto
Sequência longa de fotos em que a ordem é o assunto este

O baralho-sticky é o irmão mais próximo: os dois prendem uma tela e os dois numeram etapas. A diferença é o que a rolagem produz — lá ela empilha cards de texto sobre uma foto de fundo; aqui ela move imagens, e o texto é legenda. Se a etapa se explica por escrito, é lá. Se ela se mostra, é aqui.


Padrão editorial

Slot Regra
Olho (.eyebrow) 1 a 2 palavras. Nomeia a sequência: "Método", "Trajetória", "Como funciona".
Título (.sec-title) Uma linha só, até 32 caracteres, com uma palavra em <em>. Duas linhas levam o cabeçalho de 136px a 172px de altura e comem a folga que separa o texto do card em tela baixa (ver gotchas).
Número (.pin-num) Sempre dois dígitos, 01 a 09. É a única marca de ordem dentro da imagem, e o zero à esquerda é o que a faz ler como série.
Nome (.pin-name) 1 a 3 palavras, substantivo. "Diagnóstico", "Política de investimentos". Nomes com verbo ("Fazemos o diagnóstico") quebram o paralelismo da fila.
Linha (.pin-line) 40 a 60 caracteres, sem ponto final. É uma legenda, não uma frase de venda: diz o que aquela etapa entrega, em concreto — "O documento que orienta cada decisão", "Comitê trimestral e relatório no dia 5". Acima de 60 caracteres ela vira três linhas no card pequeno e o véu sobe demais sobre a foto.
Contador (.pin-count-all) O total, escrito à mão no markup, com dois dígitos. Trocar o número de cards sem trocar este é o erro mais fácil de cometer aqui.
alt de cada foto Descreve a foto, não a etapa — o nome já está na legenda e seria anunciado duas vezes.

Quantidade: 5 a 7. O comprimento do trecho preso é a soma das larguras: seis cards alternados pedem cerca de 3,5 telas de rolagem em 1440×900 (medido: 3.135px de trilho sobre uma seção de 4.035px). Oito passam de cinco telas, e a partir daí a pessoa que não se interessou pela terceira etapa não tem como sair sem rolar muito.

Alterne as larguras em pares. Grande, pequeno, grande, pequeno. É a alternância que faz a fila ler como exposição; seis cards iguais leem como régua, e aí o componente certo provavelmente é outro.

Escreva as seis linhas juntas, na mesma sessão. Elas passam uma depois da outra sem nada entre elas: qualquer diferença de tamanho ou de tom entre a primeira e a quarta aparece.

Especificação dos assets

Formato paisagem, recortada em 16:10 pelo object-fit:cover (4:3 abaixo de 760px)
Mínimo 1600×1000. O card grande chega a 835px de largura em 1440, e a 1.170px em tela de 2.000
Peso até 240 KB cada; são 6 na mesma dobra, e três delas carregam antes de aparecer
Recorte o assunto tem de sobreviver a corte nas laterais e a duas caixas diferentes: a mesma foto roda em 835×522 e, no celular, em 328×246
Tom contraste médio, sem branco estourado no canto superior esquerdo (o número 01 mora lá) nem no terço de baixo (o véu da legenda)
Base os últimos 30% da altura ficam sob o véu. Nada de rosto ou assunto ali
Não pode ter texto na imagem, logo de instituição real, marca d'água

Por que ph-wide-* no preview, e por que uma foto se repete. A caixa é paisagem; retrato entrando nela perderia mais da metade da altura e o preview mentiria sobre o enquadramento. Existem cinco ph-wide-* na pasta de demonstração e o padrão tem seis cards, então ph-wide-4 aparece em 02 e em 06. Aqui a repetição é aceitável — as duas etapas são de comitê e documento, e os cards estão a quatro posições de distância —, mas num site de verdade ela lê como falta de material: seis etapas pedem seis fotos.


Como usar

<link rel="stylesheet" href="componentes/midia/faixa-horizontal-pinada/estilo.css">
<script src="componentes/midia/faixa-horizontal-pinada/script.js"></script>

O script tem de ficar no fim do <body> (ou com defer): é ele que troca o modo, e a troca só é invisível se acontecer antes da primeira pintura. Medido no preview: zero entradas de layout-shift.

Ganchos

Gancho Papel
[data-pin] a seção. O script parte daqui e escreve nela as três variáveis de estado
[data-pin-track] o <ul>. Anda por transform no modo pinado; rola sozinho no nativo
[data-pin-count] o <span> do número atual — o único texto que o script reescreve
.pin-item--wide card grande (58vw). Sem a classe, o card é o pequeno (36vw)
.is-pinned posta pelo script quando o modo pinado vale. O CSS nasce no modo nativo
.is-near dobra por perto; só enquanto está lá o trilho pede camada de GPU
--pin-len px que o trilho sobra da tela. Escrita pelo script; dá altura à seção
--pin-x / --pin-p quanto o trilho andou (px) e o progresso (0 a 1) da barra
--pin-lerp suavização, .14 no padrão. 0 gruda no scroll
--pin-gap vão entre cards, clamp(18px,2.4vw,34px)
--pin-band faixa reservada no topo e no pé, clamp(140px,15vh,168px). É o teto de altura do card
--pin-inset recuo que refaz a conta do .wrap e alinha o primeiro e o último card à coluna
--pin-vh altura do palco: 100svh onde existe, 100vh de reserva

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
autoral construído do zero na biblioteca 2026-09-03 —

Origem. Padrão de interação recorrente em site premiado e em página de produto: a seção que prende a tela e transforma a rolagem vertical em travessia horizontal. Aparece na família Awwwards/FWA, em templates de Framer e Webflow, e nas páginas de produto da Apple, que o usam para atravessar uma linha de tempo. Não há referência única, e não há uma linha copiada: nessas páginas o mecanismo é sempre GSAP ScrollTrigger com pin e scrub, que a stack fixa do contrato não aceita. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca.

Decisões de tradução para o contrato. O pin do ScrollTrigger virou position:sticky num palco de uma tela, com a altura da seção calculada a partir de --pin-len — sem elemento espaçador e sem position:fixed, os dois recursos que o plugin usa e que sujariam o markup. O scrub virou --pin-lerp, uma aproximação exponencial por tempo (a mesma perseguição em 60Hz e em 120Hz), com um teto de atraso que fecha nas pontas — o scrub do plugin não tem isso, e é por isso que a versão dele às vezes solta o pin com a fila ainda andando. O laço de scroll é o LIB.onScroll do core, um rAF para a página inteira, com fallback próprio para quem colar o componente sem motion.js. Cor, fonte, raio e o respiro da seção são token; o vão, a faixa reservada, as duas larguras de card e a suavização são variáveis do prefixo. A entrada dos dois primeiros cards é data-rv do core, nunca observer próprio.

O que o padrão comum quase nunca traz e aqui é obrigatório: um modo nativo que é o padrão do CSS — sem JS, em tela estreita ou com "reduzir movimento", a mesma dobra é uma faixa de scroll-snap horizontal, com o mesmo mecanismo do midia/galeria-scroll-horizontal, em vez de uma seção quebrada; contador em aria-live; trilho focável com setas do teclado; e o texto de cada card dentro da imagem, sobre véu mascarado, como manda o §6.

Divergências conhecidas vs. produção: nenhuma — o componente ainda não rodou num site. Espere ajuste no primeiro uso real em dois pontos: o comprimento do trecho preso (que depende do número de etapas e é a decisão mais fácil de errar) e a altura do cabeçalho, que é o piso de --pin-band.