← Biblioteca componentes/midia/esteira-dupla-imagens/ficha.md CódigoTela cheia
midia/esteira-dupla-imagens

Esteira dupla de imagens

🟢 estavel complexidade alta prefixo flux- esteira marquee loop-infinito movimento-continuo reage-ao-scroll velocidade skew inclinacao fotos paisagem sangria mascara-horizontal progressivo sem-hover

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

<link rel="stylesheet" href="componentes/midia/esteira-dupla-imagens/estilo.css">
<script src="componentes/midia/esteira-dupla-imagens/script.js"></script>

markup.html · 281 linhas · 12,4 KB

<!-- ==========================================================================
     ESTEIRA DUPLA DE IMAGENS — duas fileiras em sentidos opostos, reagindo à rolagem
     Prefixo: flux-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js

     TRÊS BLOCOS IRMÃOS, filhos diretos da seção: esteira que anda para a
     esquerda, o texto centrado, esteira que anda para a direita. As esteiras
     precisam da largura da página inteira e por isso NÃO vivem dentro de um
     .wrap — quem tem .wrap é só o miolo de texto. Nenhuma margem negativa e
     nenhum 100vw: um irmão do .wrap já nasce com a largura da seção
     (contrato §4).

     PROGRESSIVO. Sem JavaScript as duas fileiras já andam, por animação de
     CSS (--flux-dur). Com JavaScript, o script assume o movimento — desliga
     a animação com .is-driven e passa a mover os dois trilhos por transform,
     em pixels por segundo, o que dá a mesma velocidade em qualquer largura
     de tela e permite a segunda camada: a reação à rolagem. Rolar rápido
     acelera as duas esteiras e inclina as fotos; parar devolve o ritmo de
     cruzeiro.

     DOIS CONJUNTOS IDÊNTICOS POR ESTEIRA, e o segundo com aria-hidden. A
     animação anda -50% do trilho, que é exatamente a largura de um conjunto
     — é o que fecha o laço sem salto. O leitor de tela ouve cinco fotos por
     esteira, não dez. Mesma receita do prova/carrossel-depoimentos e do
     prova/marquee-logos.

     AS DUAS ESTEIRAS NÃO MOSTRAM A MESMA COISA NA MESMA ORDEM. A de cima
     abre no vinhedo, a de baixo abre no veleiro. Isso importa em dois
     momentos concretos: com "reduzir movimento" as duas param no começo, e
     ordens iguais fariam a dobra parecer uma imagem espelhada; e em
     movimento, cruzar duas fileiras com o mesmo conteúdo no mesmo instante
     lê como defeito. No preview as cinco paisagens de demonstração se
     repetem entre as esteiras porque a biblioteca só tem cinco; num site
     real são dez fotos diferentes.

     FOTOS SEM loading="lazy", de propósito. O critério do lazy é a posição
     de LAYOUT da imagem, e aqui as fotos entram em cena por transform, sem
     mudar de posição no layout: uma foto adiada pode nunca disparar o
     carregamento e atravessar a esteira como retângulo vazio. Como o segundo
     conjunto repete os mesmos arquivos do primeiro, o custo real é o número
     de fotos DISTINTAS — cinco por esteira, não dez.

     LEGENDA DENTRO DA FOTO (contrato §6). O lugar de cada imagem é uma
     pílula ancorada no canto inferior esquerdo, sobre véu em gradiente com
     desfoque mascarado — nunca um parágrafo embaixo da foto, que numa
     fileira em movimento vira legenda órfã.
     ========================================================================== -->

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

  <!-- ESTEIRA DE CIMA — anda para a esquerda (data-flux-rail="-1"). -->
  <div class="flux-rail" data-flux-rail="-1" data-rv>
    <div class="flux-track" data-flux-track>

      <ul class="flux-set">
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-1.webp"
                 alt="Casal em um vinhedo ao entardecer" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Vinhedo — Serra Gaúcha</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-2.webp"
                 alt="Homem observando a cidade pela janela de um escritório" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Faria Lima — São Paulo</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-3.webp"
                 alt="Veleiro em mar aberto" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Costa — travessia</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-4.webp"
                 alt="Sala de reunião vazia com vista para a cidade" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Sala de reunião — POA</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-5.webp"
                 alt="Casal assistindo a uma partida de tênis em quadra de saibro" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Quadra de saibro — Europa</span>
            </figcaption>
          </figure>
        </li>
      </ul>

      <!-- Cópia decorativa: mesma fila, aria-hidden, sem nada focável dentro. -->
      <ul class="flux-set" aria-hidden="true">
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-1.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Vinhedo — Serra Gaúcha</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-2.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Faria Lima — São Paulo</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-3.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Costa — travessia</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-4.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Sala de reunião — POA</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-5.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Quadra de saibro — Europa</span>
            </figcaption>
          </figure>
        </li>
      </ul>

    </div>
  </div>

  <!-- O MIOLO. Único bloco com .wrap: a coluna de texto é a única coisa
       desta dobra que precisa obedecer à largura da página. -->
  <div class="wrap flux-body">
    <span class="eyebrow" data-rv>Presença</span>
    <h2 class="sec-title flux-title ttl">Perto de onde a sua <em>vida</em> acontece</h2>
    <p class="sec-lead flux-lead" data-rv data-d="1">
      Escritórios em São Paulo e Porto Alegre, reuniões onde o cliente
      estiver e uma equipe que viaja para conhecer a empresa, a fazenda e a
      família.
    </p>
  </div>

  <!-- ESTEIRA DE BAIXO — anda para a direita (data-flux-rail="1"). Mesmas
       fotos em outra ordem: ver o comentário do cabeçalho. -->
  <div class="flux-rail" data-flux-rail="1" data-rv data-d="1">
    <div class="flux-track" data-flux-track>

      <ul class="flux-set">
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-3.webp"
                 alt="Veleiro em mar aberto" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Costa — travessia</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-4.webp"
                 alt="Sala de reunião vazia com vista para a cidade" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Sala de reunião — POA</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-5.webp"
                 alt="Casal assistindo a uma partida de tênis em quadra de saibro" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Quadra de saibro — Europa</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-1.webp"
                 alt="Casal em um vinhedo ao entardecer" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Vinhedo — Serra Gaúcha</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-2.webp"
                 alt="Homem observando a cidade pela janela de um escritório" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Faria Lima — São Paulo</span>
            </figcaption>
          </figure>
        </li>
      </ul>

      <ul class="flux-set" aria-hidden="true">
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-3.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Costa — travessia</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-4.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Sala de reunião — POA</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-5.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Quadra de saibro — Europa</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-1.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Vinhedo — Serra Gaúcha</span>
            </figcaption>
          </figure>
        </li>
        <li class="flux-item">
          <figure class="flux-card">
            <img class="flux-img" src="../../../showroom/demo/ph-wide-2.webp" alt="" decoding="async">
            <span class="flux-veil"></span>
            <figcaption class="flux-cap">
              <span class="pill flux-tag">Faria Lima — São Paulo</span>
            </figcaption>
          </figure>
        </li>
      </ul>

    </div>
  </div>

</section>

estilo.css · 255 linhas · 11,4 KB

/* ==========================================================================
   ESTEIRA DUPLA DE IMAGENS — prefixo flux-
   Duas fileiras de fotos paisagem atravessando a tela em sentidos opostos,
   com uma frase centrada entre elas. Sem JS as fileiras andam por animação
   de CSS; com JS o script assume o movimento e acrescenta a reação à
   rolagem.

   Tokens usados:
     --dark-rgb        véu em gradiente sob a legenda, dentro da foto
     --ease            curva única do projeto — transições de inclinação
     --light           texto e contorno da pílula sobre a foto (ilha escura)
     --radius-media    canto da foto
     --rule            fundo do quadro enquanto a foto não pintou

   ILHA ESCURA. Tudo que está sobre a foto lê --light, e não --fg: assim a
   mesma dobra roda numa seção .sec-paper sem virar texto escuro sobre véu
   escuro. O que está fora das fotos (olho, título, apoio) segue o escopo de
   tom da seção, como manda o §3 do contrato.

   OS DOIS PARAFUSOS DE VELOCIDADE SÃO O MESMO EM UNIDADES DIFERENTES.
   --flux-dur é a volta completa da animação de CSS (fallback sem JS);
   --flux-speed é a velocidade em px/s que o script usa. A conversão é
   dur = largura do conjunto ÷ speed. Medido a 1440px de janela: um conjunto
   mede 2 270px, logo 2270 / 40 = 56,7s ≈ o padrão de 56s. Mexeu num, mexa
   no outro — senão o fallback anda num ritmo e o script noutro.

   E ELES NÃO PODEM CASAR EM TODA LARGURA. Duração fixa significa velocidade
   variável: em 390px o conjunto mede 1 356px e a mesma animação de 56s anda
   a 24px/s, 40% do ritmo do desktop. O script, que conta em px/s, anda igual
   em qualquer tela. É a principal razão de ele assumir.
   ========================================================================== */

.flux{
  /* ---- Forma ---- */
  --flux-card:clamp(240px,30vw,440px);   /* largura da foto */
  --flux-gap:clamp(14px,1.6vw,24px);     /* vão entre uma foto e a próxima */
  --flux-space:clamp(30px,5vh,64px);     /* respiro entre esteira e texto */
  --flux-veil:38%;                       /* altura do véu, em fração da foto */

  /* ---- Ritmo ---- */
  --flux-dur:56s;      /* volta completa SEM JavaScript (ver cabeçalho) */
  --flux-speed:40;     /* velocidade de cruzeiro COM JavaScript, em px/s.
                          0 = esteiras paradas, e aí nada mais anda: o teto
                          do impulso é múltiplo desta base. */

  /* ---- Reação à rolagem (lidas pelo script) ---- */
  --flux-react:.35;    /* fração da velocidade da página que vira impulso */
  --flux-boost:6;      /* teto do impulso, em múltiplos de --flux-speed */
  --flux-skew-max:6deg;/* inclinação máxima da foto, no teto do impulso */

  /* Escrita pelo script a cada quadro em que muda. Declarada aqui para o
     componente funcionar colado sem o script.js: sem JS fica 0deg e as
     fotos ficam retas. */
  --flux-skew:0deg;

  display:flex;
  flex-direction:column;
  gap:var(--flux-space);
}

/* ---------------------------------------------------------------- miolo de texto */
/* Único bloco com .wrap. Centrado no eixo horizontal porque as duas esteiras
   já ocupam a página inteira: um texto alinhado à esquerda entre duas
   fileiras simétricas puxa a dobra para um canto sem motivo.
   Não usa .sec-head do core porque .sec-head não é .wrap — aqui os dois
   papéis (coluna da página e cabeçalho centrado) caem no mesmo elemento. */
.flux-body{text-align:center}

/* h2.sec-title do core é (0,1,1); .flux-title sozinha perderia. O seletor
   duplo é o que garante a centralização qualquer que seja a ordem em que as
   folhas foram coladas (§6.4). */
.flux .flux-title{margin-inline:auto}
.flux .flux-lead{margin-inline:auto;max-width:56ch}

/* ---------------------------------------------------------------- esteira */
/* A máscara é o que faz a fileira "atravessar" em vez de "começar e acabar":
   sem ela o recorte corta a foto no meio com borda dura e o olho lê que
   alguma coisa quebrou. 6% de cada lado — a 1440px, 86px de dissolvência. */
.flux-rail{
  overflow:hidden;
  /* Sentido do trilho, lido pelas fotos na conta da inclinação. O valor
     numérico mora no data-flux-rail para haver uma fonte só: o script lê o
     mesmo atributo. */
  --flux-dir:-1;
  --flux-dir-back:1;
  mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);
}
.flux-rail[data-flux-rail="1"]{--flux-dir:1;--flux-dir-back:-1}

.flux-track{
  display:flex;
  width:max-content;
  animation:fluxRun var(--flux-dur) linear infinite;
  /* Só o elemento que anda ganha camada própria. */
  will-change:transform;
}
/* Movimento reduzido: as duas esteiras param onde estão, no começo da fila.
   O script nem assume nesse caso — não põe .is-driven e não escreve
   transform —, então esta regra é a única coisa que decide. Daí a ordem
   editorial da ficha: as cinco primeiras fotos de cada esteira têm de ser
   as melhores, porque podem ser as únicas que alguém vê. */
@media(prefers-reduced-motion:reduce){
  .flux-track{animation-name:none}
}

/* O trilho anda -50%, que é a largura EXATA de um conjunto — e só é exata
   porque o vão entre conjuntos é padding do próprio conjunto, não gap do
   trilho. Com gap no trilho a largura total seria 2×conjunto + 1 vão, -50%
   cairia no lugar errado e o laço daria um solavanco por volta. */
@keyframes fluxRun{
  from{transform:translateX(0)}
  to{transform:translateX(-50%)}
}
.flux-rail[data-flux-rail="1"] .flux-track{animation-direction:reverse}

/* Quando o script assume, a animação sai de cena e quem escreve o transform
   é ele. animation-name e não a abreviação animation: a abreviação também
   zeraria o animation-direction da regra acima, e o dia em que alguém tirar
   o .is-driven a esteira de baixo voltaria andando para o lado errado. */
.flux.is-driven .flux-track{animation-name:none}

.flux-set{
  display:flex;
  gap:var(--flux-gap);
  /* Mantém o vão na emenda entre as duas cópias — ver o comentário do
     keyframe. */
  padding-inline-end:var(--flux-gap);
}

.flux-item{flex:0 0 var(--flux-card);min-width:0}

/* ---------------------------------------------------------------- foto */
/* A INCLINAÇÃO É DO QUADRO INTEIRO, não da imagem dentro dele. Inclinar só
   a <img> a descolaria das bordas do quadro e abriria fresta do fundo nos
   dois cantos; inclinando o quadro, foto, véu e legenda viajam juntos.

   Ela não abre nem fecha o vão entre fotos vizinhas: skewX desloca cada
   ponto por y·tanθ, e como toda a fileira tem a mesma altura e o mesmo
   transform-origin, duas fotos vizinhas se deslocam pelo mesmo tanto em
   cada altura. Medido a 6deg: vão constante em 22,00px do topo à base.

   A transição é o que faz a volta ser macia: o script reescreve --flux-skew
   quadro a quadro enquanto o impulso decai, e sem ela a foto endireitaria
   em degraus. .3s é curto o bastante para a subida ainda parecer resposta,
   e não atraso. */
.flux-card{
  position:relative;
  aspect-ratio:16/10;
  overflow:hidden;
  border-radius:var(--radius-media);
  background:var(--rule);
  margin:0;
  transform:skewX(calc(var(--flux-skew) * var(--flux-dir)));
  transition:transform .3s var(--ease);
}
/* Sem movimento reduzido não há inclinação para animar — o script não
   escreve --flux-skew. A transição some junto para não sobrar máquina
   ligada sem nada a transicionar. */
@media(prefers-reduced-motion:reduce){
  .flux-card{transition:none}
}

.flux-img{
  position:absolute;inset:0;
  width:100%;height:100%;
  object-fit:cover;
  object-position:50% 50%;
}

/* VÉU E LEGENDA SÃO DOIS ELEMENTOS, e não um só com padding — a mesma
   separação do conteudo/pilares-texto-na-foto, e por um motivo medido: a
   máscara vale para o elemento INTEIRO, filhos incluídos. Com a pílula
   dentro do véu, o topo dela caía junto com o degradê (60% de opacidade em
   390px, onde o véu é baixo) e a legenda ficava meio apagada. Separados, a
   máscara amacia só o véu e o texto fica cheio.

   A ALTURA É FRAÇÃO DA FOTO, não padding em px. Com padding fixo, o mesmo
   véu que ocupa 35% de uma foto de 270px ocupa 52% de uma de 156px — medido
   em 390px, metade da imagem sumia sob o degradê. Em fração, a proporção é
   a mesma em qualquer largura.

   O desfoque é SEMPRE mascarado (contrato §6): sem a máscara, backdrop-filter
   em caixa reta desenha uma linha dura contra a parte nítida da foto e a
   faixa lê "chapada". Blur de 3px, o mesmo do midia/galeria-scroll-horizontal.
   Medido nesta dobra, com 20 véus e as fotos andando: 4,20ms de mediana de
   quadro com e sem o desfoque — ele não custa na linha principal, porque
   quem o desenha é o compositor. */
.flux-veil{
  position:absolute;left:0;right:0;bottom:0;z-index:1;
  height:var(--flux-veil);
  pointer-events:none;
  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%);
}

.flux-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  padding:0 16px 14px;
  margin:0;
  pointer-events:none;
}

/* Reaproveita a .pill do core — forma, respiro e tracking vêm de lá; só o
   tom muda, porque a pílula está sobre foto. Seletor duplo de propósito:
   .pill e .flux-tag têm a mesma especificidade, e o §6.4 proíbe depender de
   qual folha carregou primeiro.

   A contra-inclinação é o que mantém a legenda em pé enquanto a foto
   inclina. Sem ela o rótulo tomba junto e a dobra passa a parecer quebrada,
   e não veloz. A origem é o canto inferior esquerdo — o mesmo ponto em que
   a pílula está ancorada —, então ela endireita sem sair do lugar. */
.flux-card .flux-tag{
  display:inline-block;
  max-width:100%;
  color:var(--light);
  border-color:color-mix(in srgb,var(--light) 32%,transparent);
  transform:skewX(calc(var(--flux-skew) * var(--flux-dir-back)));
  transform-origin:0 100%;
  transition:transform .3s var(--ease);
  /* Uma linha, sempre: pílula de duas linhas perde a forma de pílula. O par
     overflow + ellipsis é a rede — passou do que cabe, a legenda corta com
     reticências em vez de ser decepada pelo recorte da foto. Medido em
     390px: cabem 24 caracteres na pílula, e é esse o limite editorial da
     ficha. Aos 30, ela vazava 13,1px para fora do quadro. */
  white-space:nowrap;
  overflow:hidden;
  text-overflow:ellipsis;
}
@media(prefers-reduced-motion:reduce){
  .flux-card .flux-tag{transition:none}
}

/* ---------------------------------------------------------------- mobile */
/* A reação à rolagem CONTINUA no toque — é lá que ela fica mais bonita,
   porque o gesto de arremesso produz velocidades que o mouse não alcança.
   O que muda é o ganho: --flux-react cai de .35 para .2, senão o mesmo
   arremesso passa do teto do impulso a cada gesto e a esteira vira borrão.
   O teto da inclinação também baixa: em foto pequena, 6deg é proporcional-
   mente muito mais tombo do que em foto de 440px. */
@media(max-width:760px){
  .flux{
    --flux-card:min(64vw,300px);
    --flux-gap:12px;
    --flux-space:clamp(24px,4vh,40px);
    --flux-react:.2;
    --flux-skew-max:4deg;
  }
  .flux-cap{padding:0 12px 11px}
}

script.js · 321 linhas · 13,3 KB

/* ==========================================================================
   ESTEIRA DUPLA DE IMAGENS — o script assume o movimento e o liga à rolagem.

   O componente ANDA SEM ESTE ARQUIVO: as duas esteiras têm uma animação de
   CSS. O que o script acrescenta são duas coisas que o CSS não sabe fazer:

     1. velocidade em PIXELS POR SEGUNDO, e não em "uma volta a cada N
        segundos". Duração fixa quer dizer velocidade variável — em tela
        estreita o conjunto é menor e a mesma animação anda mais devagar.
        Contando em px/s, a dobra tem o mesmo ritmo em 390px e em 1440px;
     2. a REAÇÃO À ROLAGEM: a velocidade da página vira um impulso que soma
        à velocidade de cruzeiro e inclina as fotos, e que decai sozinho.

   A TROCA DE MÃOS É SEM SALTO. Antes de desligar a animação, o script lê a
   matriz que ela produziu naquele quadro (getComputedStyle devolve o valor
   ANIMADO, não o declarado), converte em deslocamento e continua dali. Sem
   isso o trilho voltaria ao zero no instante em que o script rodasse — um
   corte que a máscara das pontas esconde só em parte. Medido: com a leitura,
   o salto é de 0,00px; sem ela, é a distância que a animação já tinha
   andado (~1 a 3px num carregamento local, e muito mais numa página lenta,
   porque o CSS começa a andar no primeiro quadro e o script só roda depois
   do HTML inteiro).

   O IMPULSO É ENERGIA, NÃO DIREÇÃO. Rolar para cima acelera tanto quanto
   rolar para baixo, e cada esteira acelera no SEU sentido — as duas nunca
   trocam de lado. A alternativa (o sentido seguir o da rolagem) foi
   descartada: a inversão faz o olho perder o rastro de qual foto estava
   olhando, e num par de fileiras cruzadas o efeito lê como falha. Quem
   quiser a outra leitura tem a variação "direção segue o scroll" na ficha.

   UM rAF POR INSTÂNCIA, e ele dorme fora da tela (IntersectionObserver) e
   quando não há nada a mover (--flux-speed:0 sem impulso). Nenhum listener
   além do laço de scroll do core e de um resize passivo.

   MOVIMENTO REDUZIDO: o script NEM ASSUME. Não põe .is-driven, não escreve
   transform nenhum e não assina o scroll — quem para as esteiras é o
   @media do próprio estilo.css. Um só lugar decide, e ele é declarativo.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-flux]');
  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;
  }

  /* O laço único do core é o assinante certo para scroll de PÁGINA (§7.4).
     O fallback só existe para quem colar o componente sem o motion.js, e
     mantém o contrato que importa aqui: no máximo uma amostra por quadro.
     É isso que torna a medição de velocidade estável — trackpad e roda
     inercial disparam eventos em rajada, com dt de 2 a 4ms entre eles, e
     medir evento a evento devolveria picos de dezenas de milhares de px/s. */
  function assinarScroll(fn) {
    if (window.LIB && window.LIB.onScroll) { window.LIB.onScroll(fn); return; }
    var pedido = 0;
    addEventListener('scroll', function () {
      if (pedido) return;
      pedido = requestAnimationFrame(function () { pedido = 0; fn(); });
    }, { passive: true });
    fn();
  }

  function agora() {
    return (window.performance && performance.now) ? performance.now() : +new Date();
  }

  function num(estilo, nome, padrao) {
    var v = parseFloat(estilo.getPropertyValue(nome));
    return isNaN(v) ? padrao : v;
  }

  /* Deslocamento horizontal em px que o elemento tem NESTE quadro, venha ele
     de animação de CSS ou de transform inline. matrix(a,b,c,d,tx,ty) tem o
     tx na posição 4; matrix3d guarda o mesmo na 12. */
  function xAtual(el) {
    var t = getComputedStyle(el).transform;
    if (!t || t === 'none') return 0;
    var abre = t.indexOf('(');
    if (abre < 0) return 0;
    var n = t.slice(abre + 1, t.lastIndexOf(')')).split(',');
    if (n.length === 6)  return parseFloat(n[4])  || 0;
    if (n.length === 16) return parseFloat(n[12]) || 0;
    return 0;
  }

  function iniciar(raiz) {
    /* Movimento reduzido: saída silenciosa, e o CSS resolve. */
    if (reduzido()) return;

    var rails = raiz.querySelectorAll('[data-flux-rail]');
    if (!rails.length) return;

    var estilo   = getComputedStyle(raiz);
    var base     = num(estilo, '--flux-speed', 40);      /* px/s */
    var react    = num(estilo, '--flux-react', .35);
    var tetoMult = num(estilo, '--flux-boost', 6);       /* múltiplos da base */
    var skewMax  = num(estilo, '--flux-skew-max', 6);    /* graus */
    if (base < 0) base = 0;
    var teto = base * tetoMult;

    var trilhos = [];
    for (var i = 0; i < rails.length; i++) {
      var el = rails[i].querySelector('[data-flux-track]');
      /* Um conjunto só não fecha laço: é o segundo que dá a medida e o
         ponto de volta. Sem ele, deixa a animação de CSS em paz. */
      if (!el || el.children.length < 2) continue;
      trilhos.push({
        el: el,
        dir: parseFloat(rails[i].getAttribute('data-flux-rail')) < 0 ? -1 : 1,
        pos: 0,
        largura: 0
      });
    }
    if (!trilhos.length) return;

    var boost = 0, dentro = false, laco = 0, antes = 0;
    var ultimoY = 0, ultimoT = 0;
    var skewEscrito = -1;

    /* ------------------------------------------------------------ medidas */

    /* A largura de um conjunto é a distância do primeiro ao segundo — o vão
       da emenda entra na conta sozinho, porque ele é padding do conjunto.
       Os dois estão dentro do mesmo trilho transformado, então o transform
       do pai entra igual nos dois e some na subtração.

       getBoundingClientRect e não offsetLeft: offsetLeft devolve INTEIRO.
       Medido nesta dobra, o conjunto tem 2275,16px e o offsetLeft dá 2275 —
       0,16px a menos do que a animação de CSS usa em -50%, que é o valor
       fracionário. Não acumula (a volta reposiciona a cada laço), mas é
       diferença de graça, e é ela que faz o ponto de volta cair no pixel
       exato em vez de perto dele.

       Não há remedição no load das imagens, e isso é intencional: o item tem
       largura fixa (flex-basis em --flux-card) e o quadro tem aspect-ratio,
       então a fila já mede o mesmo antes de qualquer foto chegar. A legenda
       é absoluta, então nem a troca de fonte mexe na conta. */
    function medir(el) {
      return el.children[1].getBoundingClientRect().left -
             el.children[0].getBoundingClientRect().left;
    }

    function escrever() {
      for (var i = 0; i < trilhos.length; i++) {
        var t = trilhos[i];
        /* pos anda sempre para a frente, de 0 até a largura de um conjunto.
           O sentido entra aqui: o trilho que vai para a esquerda desloca
           -pos; o que vai para a direita nasce um conjunto atrás e sobe até
           zero. Os dois caminhos são visualmente contínuos no ponto de
           volta, porque os dois conjuntos são idênticos. */
        var x = t.dir < 0 ? -t.pos : (t.pos - t.largura);
        t.el.style.transform = 'translate3d(' + x.toFixed(2) + 'px,0,0)';
      }
    }

    /* A inclinação sai da mesma grandeza que a aceleração: no teto do
       impulso ela é --flux-skew-max, e cai proporcionalmente até zero.
       O 5 é o ponto em que satura — abaixo do teto de 6, para o efeito
       chegar ao máximo antes de a velocidade chegar.

       Só escreve quando MUDA de verdade. Custom property em elemento raiz
       invalida o estilo de todos os descendentes que a leem (aqui, 20
       quadros e 20 pílulas): escrever o mesmo valor a cada quadro pagaria
       essa conta para nada, e em repouso a conta seria paga eternamente. */
    function escreverSkew() {
      var g = 0;
      if (base > 0) {
        g = Math.min(boost / base / 5, 1) * skewMax;
        if (g > skewMax) g = skewMax;
      }
      if (Math.abs(g - skewEscrito) < .04) return;
      skewEscrito = g;
      raiz.style.setProperty('--flux-skew', g.toFixed(2) + 'deg');
    }

    /* ------------------------------------------------------------ troca de mãos */

    /* Lê a posição que a animação de CSS já produziu, escreve o transform
       equivalente e SÓ ENTÃO desliga a animação — tudo na mesma tarefa, para
       o navegador resolver o estilo uma vez e não haver um quadro com a
       animação desligada e nenhum transform inline (que seria um pulo a
       zero, visível justamente no meio da esteira, onde a máscara não
       cobre). */
    function assumir() {
      for (var i = 0; i < trilhos.length; i++) {
        var t = trilhos[i];
        t.largura = medir(t.el);
        var x = xAtual(t.el);
        t.pos = t.dir < 0 ? -x : (x + t.largura);
        if (t.largura > 0) {
          while (t.pos >= t.largura) t.pos -= t.largura;
          while (t.pos < 0) t.pos += t.largura;
        }
      }
      escrever();
      raiz.classList.add('is-driven');
    }

    function remedir() {
      for (var i = 0; i < trilhos.length; i++) {
        var t = trilhos[i];
        var nova = medir(t.el);
        /* Reescala em vez de zerar: mudar de largura no meio da volta com
           pos cru faria a fila saltar de lugar ao virar o celular. */
        if (t.largura > 0 && nova > 0) t.pos = t.pos / t.largura * nova;
        t.largura = nova;
      }
      escrever();
    }

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

    function passo(quadro) {
      /* Teto de 50ms: voltar de uma aba em segundo plano daria um dt enorme
         e a esteira saltaria meia volta de uma vez. */
      var dt = antes ? Math.min((quadro - antes) / 1000, .05) : 0;
      antes = quadro;

      /* Decaimento de 8% por quadro A 60Hz, escrito por tempo. Cru
         (boost *= .92 a cada quadro), uma tela de 120Hz devolveria o ritmo
         no dobro da velocidade. Meia-vida: ln(.5)/ln(.92) ≈ 8,3 quadros,
         ou ~138ms. */
      if (boost > 0) {
        boost *= Math.pow(.92, dt * 60);
        if (boost < .4) boost = 0;
      }

      var v = base + boost;
      for (var i = 0; i < trilhos.length; i++) {
        var t = trilhos[i];
        if (t.largura <= 0) continue;
        t.pos += v * dt;
        /* while e não if: um impulso no teto pode passar de uma volta num
           quadro só se as fotos forem pequenas. */
        while (t.pos >= t.largura) t.pos -= t.largura;
      }

      escrever();
      escreverSkew();

      laco = (dentro && (base > 0 || boost > 0)) ? requestAnimationFrame(passo) : 0;
    }

    function ligar() {
      if (laco || !dentro) return;
      if (base <= 0 && boost <= 0) return;
      antes = 0;
      laco = requestAnimationFrame(passo);
    }

    function desligar() {
      if (!laco) return;
      cancelAnimationFrame(laco);
      laco = 0;
    }

    /* ------------------------------------------------------------ rolagem */

    /* Velocidade da PÁGINA, em px/s, amostrada uma vez por quadro. O maior
       valor vence: um impulso em curso só é substituído por outro maior, e
       o decaimento do laço é quem o traz de volta. Assim uma rolagem longa
       mantém a esteira acelerada sem somar indefinidamente. */
    function amostrar() {
      var t = agora();
      var y = window.pageYOffset || document.documentElement.scrollTop || 0;
      if (!ultimoT) { ultimoT = t; ultimoY = y; return; }

      var dt = t - ultimoT;
      var dy = Math.abs(y - ultimoY);
      ultimoT = t; ultimoY = y;

      if (!dentro || base <= 0) return;

      /* Piso de 6ms e teto de 200ms no denominador. O piso protege da
         divisão por um dt de trackpad; o teto é o que faz um salto de
         âncora (scroll-behavior:smooth do core, ou um scrollTo instantâneo
         depois de a página ficar parada) ainda contar como movimento rápido
         em vez de virar velocidade ridícula por causa do intervalo ocioso. */
      if (dt < 6) dt = 6;
      if (dt > 200) dt = 200;

      var alvo = (dy / (dt / 1000)) * react;
      if (alvo > boost) boost = Math.min(alvo, teto);
      ligar();
    }

    /* ------------------------------------------------------------ tela */

    var pedido = 0;
    addEventListener('resize', function () {
      if (pedido) return;
      /* Uma medida por quadro: medir() lê layout, e fazer isso a cada evento
         de resize trava a janela ao redimensionar. */
      pedido = requestAnimationFrame(function () { pedido = 0; remedir(); });
    }, { passive: true });

    var io = new IntersectionObserver(function (entradas) {
      for (var i = 0; i < entradas.length; i++) {
        dentro = entradas[i].isIntersecting;
        if (dentro) { ligar(); continue; }
        desligar();
        /* Fora da tela o impulso não decai (o laço dorme). Zerar aqui é o
           que impede a dobra de reaparecer acelerada e torta depois de a
           pessoa ter rolado meia página. */
        boost = 0;
        escreverSkew();
      }
    }, { threshold: 0 });

    assumir();
    assinarScroll(amostrar);
    io.observe(raiz);
  }

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

Duas fileiras de fotos paisagem grandes atravessando a tela em sentidos opostos, uma acima e outra abaixo de uma frase centrada. Não são logos nem miniaturas: são imagens de 432px de largura em 16:10, com o lugar de cada uma escrito numa pílula dentro da própria foto. As duas andam sozinhas, devagar, e reagem à rolagem — quem passa rápido pela dobra vê as fileiras acelerarem até quase sete vezes o ritmo de cruzeiro e as fotos inclinarem uns graus; quem para vê tudo voltar ao passo normal em cerca de um quarto de segundo. O olho não escolhe uma foto: ele atravessa a dobra junto com elas, e o que fica é a impressão de quantidade e de lugar.

Resolve a dobra de "onde estamos" quando o material é foto e não mapa. Uma grade de seis imagens conta o mesmo, mas conta como catálogo: cada foto vira um item a examinar, e a pessoa para em uma delas. A esteira troca o inventário pela presença — mostra dez lugares sem pedir que ninguém olhe dez lugares, e o texto no meio fica sendo a única coisa que se lê. A reação à rolagem é o que impede a dobra de ser só decoração: ela responde ao gesto de quem está passando, e por isso lê como parte da página e não como um GIF colado nela.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho Uma palavra, no máximo duas. É rótulo de dobra, não frase.
Título Uma linha que quebra em duas, 30 a 42 caracteres. Uma palavra em <em> — o realce em itálico na cor de marca.
Apoio Uma frase, 120 a 180 caracteres. Ela é o único texto que alguém vai ler de verdade nesta dobra: tem de dizer o argumento inteiro sozinha.
Fotos por esteira Exatamente 5, e as duas esteiras com fotos diferentes. Menos de 5 encurta o laço; mais de 5 só adia o problema de arrumar boas fotos.
Legenda Até 24 caracteres. Lugar e recorte, separados por travessão: "Vinhedo — Serra Gaúcha". Sem verbo, sem frase, sem ponto final.
Ordem As cinco primeiras de cada esteira são as melhores. Com "reduzir movimento" ligado, as esteiras congelam no começo da fila, e são essas as únicas que alguém vê.

O que mantém a dobra coesa é o paralelismo das legendas: se uma nomeia cidade, todas nomeiam; se uma nomeia atividade, todas nomeiam. Legenda de tipo misturado ("Faria Lima — SP" ao lado de "Onde tudo começou") faz a fileira parecer duas coisas diferentes passando juntas. E as duas esteiras têm de contar o mesmo assunto em ordens diferentes — não uma o Brasil e outra o exterior, porque em movimento ninguém percebe a divisão.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/midia/esteira-dupla-imagens/estilo.css">
<script src="componentes/midia/esteira-dupla-imagens/script.js"></script>

O <script> é opcional: sem ele as duas esteiras andam por animação de CSS. O que se perde é a velocidade em px/s e a reação à rolagem.

Ganchos

Gancho Papel
[data-flux] Raiz. O script parte daqui, nunca do documento, e é aqui que ele escreve --flux-skew.
[data-flux-rail="-1"] / ="1" O recorte de cada esteira e o sentido dela. Uma fonte só: o CSS e o script leem o mesmo atributo.
[data-flux-track] O que anda. Tem de conter exatamente dois .flux-set idênticos, o segundo com aria-hidden.
--flux-card / --flux-gap Largura da foto e vão até a próxima. clamp(240px,30vw,440px) e clamp(14px,1.6vw,24px).
--flux-space Respiro entre esteira e texto.
--flux-veil Altura do véu, em fração da foto. 38%.
--flux-dur Volta completa da animação de CSS — o ritmo de quem não tem JavaScript. 56s.
--flux-speed Velocidade de cruzeiro do script, em px/s. 40. 0 deixa tudo parado.
--flux-react Fração da velocidade da página que vira impulso. .35 no desktop, .2 no toque.
--flux-boost Teto do impulso, em múltiplos de --flux-speed. 6.
--flux-skew-max Inclinação máxima da foto, no teto do impulso. 6deg no desktop, 4deg no toque.
--flux-skew Escrita pelo script na raiz. 0deg sem script.
--flux-dir / --flux-dir-back Sentido da esteira e o inverso: as fotos inclinam por um, a legenda endireita pelo outro.
.is-driven Na raiz: o script assumiu e a animação de CSS está desligada.

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 premium: a esteira de imagens cuja velocidade se acopla à rolagem, com inclinação proporcional à aceleração. Aparece em vitrines de estúdio premiadas no Awwwards, nos componentes de marquee que acompanham os templates de Framer, e nas faixas de produto que Apple e Nike usam em página de lançamento. Não há referência única e nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. As implementações que se vê por aí são GSAP com ScrollTrigger e scrub, ou React com uma biblioteca de motion — nenhuma das duas caberia aqui, e a tradução para vanilla foi o trabalho.

Decisões de tradução para o contrato.

Divergências conhecidas vs. o padrão comum.