← Biblioteca componentes/listas/lista-imagem-flutuante/ficha.md CódigoTela cheia
listas/lista-imagem-flutuante

Lista com imagem flutuante

🟢 estavel complexidade alta prefixo peek- lista servicos imagem hover cursor seguidor lerp galeria indice linhas teclado

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

<link rel="stylesheet" href="componentes/listas/lista-imagem-flutuante/estilo.css">
<script src="componentes/listas/lista-imagem-flutuante/script.js"></script>

markup.html · 143 linhas · 7,9 KB

<!-- ==========================================================================
     LISTA COM IMAGEM FLUTUANTE — seis linhas grandes e uma foto que segue a mão
     Prefixo: peek-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. O seguidor é um rAF de ~60 linhas.

     A DOBRA É UMA LISTA DE VERDADE. Seis <li>, cada um com um <a> que leva a
     algum lugar. Tudo o que a foto faz é tratamento visual por cima disso: se
     o script não carregar, o que sobra é uma lista de links legível, com
     número, título, categoria e miniatura — nada fica escondido atrás do
     gesto. É a diferença entre "carrossel de imagens dirigido pela mão" e
     carrossel: aqui não há estado que só o ponteiro alcança.

     A FOTO FLUTUANTE NÃO ESTÁ NESTE ARQUIVO, e é de propósito. Ela é
     `position:fixed`, e um ancestral com `transform` ou `filter` transforma
     fixed em absolute — o que inclui QUALQUER elemento com [data-rv], que
     carrega `filter:blur(14px)` até receber .in. Escrita no markup, ela
     acabaria dentro do .wrap ou de um bloco com reveal na primeira vez que
     alguém reorganizasse a dobra. Criada pelo script como filha DIRETA da
     <section>, não há onde errar. Por isso a seção também não leva data-rv:
     quem entra são o olho, o título, o apoio e as linhas.

     MINIATURA INLINE, NÃO SUBSTITUTA. Cada linha traz um <img class="peek-thumb">
     que o CSS esconde onde existe hover. Não é fallback de JavaScript: é o
     desenho da versão de toque, onde a foto que persegue o dedo não faz
     sentido nenhum (não há ponteiro para perseguir, e o dedo cobre a foto).
     O script nem chega a rodar num aparelho de toque — ver o guard do
     script.js.

     data-peek-img / data-peek-alt VIVEM NA LINHA, não numa lista paralela no
     script. Trocar a foto de uma frente de trabalho é editar o <a> dela e
     mais nada; e a ordem das linhas deixa de estar acoplada a um array.

     O alt da miniatura é vazio DE PROPÓSITO: ela está dentro de um link cujo
     texto já diz "Gestão de carteira", e repetir "três pessoas em um
     escritório" ali só alonga o que o leitor de tela anuncia. A descrição
     honesta da foto está no data-peek-alt, que o script põe na imagem
     flutuante — ver o gotcha na ficha.
     ========================================================================== -->

<section class="sec-deep sec-pad peek" id="especialidades" data-peek>
  <div class="wrap">

    <span class="eyebrow" data-rv>Especialidades</span>
    <h2 class="sec-title ttl">O que fazemos, em <em>seis</em> linhas</h2>
    <p class="sec-lead" data-rv data-d="1">
      Seis frentes, uma equipe. O que muda de uma família para outra é a ordem
      em que elas entram — e é isso que o diagnóstico define.
    </p>

    <!-- ENTRADA. As três primeiras linhas escalonam; da quarta em diante cada
         uma entra sozinha quando a rolagem a traz para a tela, e um delay ali
         só atrasaria o que a pessoa já pediu. Mesma leitura do
         midia/galeria-scroll-horizontal. -->
    <ul class="peek-list" data-peek-list>

      <li class="peek-item" data-rv>
        <a class="peek-row" href="#carteira"
           data-peek-img="../../../showroom/demo/ph-4.webp"
           data-peek-alt="Três pessoas em um escritório com vista para a cidade">
          <span class="peek-num">01</span>
          <span class="peek-title">Gestão de carteira</span>
          <span class="pill peek-cat">Investimentos</span>
          <img class="peek-thumb" src="../../../showroom/demo/ph-4.webp" alt="" loading="lazy" decoding="async">
          <svg class="peek-arrow" width="34" height="12" viewBox="0 0 34 12" fill="none" aria-hidden="true" focusable="false">
            <path d="M0 6H32M32 6L26.5 1M32 6L26.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </a>
      </li>

      <li class="peek-item" data-rv data-d="1">
        <a class="peek-row" href="#sucessao"
           data-peek-img="../../../showroom/demo/ph-2.webp"
           data-peek-alt="Família caminhando por uma alameda">
          <span class="peek-num">02</span>
          <span class="peek-title">Planejamento sucessório</span>
          <span class="pill peek-cat">Família</span>
          <img class="peek-thumb" src="../../../showroom/demo/ph-2.webp" alt="" loading="lazy" decoding="async">
          <svg class="peek-arrow" width="34" height="12" viewBox="0 0 34 12" fill="none" aria-hidden="true" focusable="false">
            <path d="M0 6H32M32 6L26.5 1M32 6L26.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </a>
      </li>

      <li class="peek-item" data-rv data-d="2">
        <a class="peek-row" href="#internacional"
           data-peek-img="../../../showroom/demo/ph-6.webp"
           data-peek-alt="Globo antigo em uma biblioteca">
          <span class="peek-num">03</span>
          <span class="peek-title">Estruturas internacionais</span>
          <span class="pill peek-cat">Offshore</span>
          <img class="peek-thumb" src="../../../showroom/demo/ph-6.webp" alt="" loading="lazy" decoding="async">
          <svg class="peek-arrow" width="34" height="12" viewBox="0 0 34 12" fill="none" aria-hidden="true" focusable="false">
            <path d="M0 6H32M32 6L26.5 1M32 6L26.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </a>
      </li>

      <li class="peek-item" data-rv>
        <a class="peek-row" href="#previdencia"
           data-peek-img="../../../showroom/demo/ph-5.webp"
           data-peek-alt="Homem mais velho e criança lendo juntos">
          <span class="peek-num">04</span>
          <span class="peek-title">Previdência e proteção</span>
          <span class="pill peek-cat">Longo prazo</span>
          <img class="peek-thumb" src="../../../showroom/demo/ph-5.webp" alt="" loading="lazy" decoding="async">
          <svg class="peek-arrow" width="34" height="12" viewBox="0 0 34 12" fill="none" aria-hidden="true" focusable="false">
            <path d="M0 6H32M32 6L26.5 1M32 6L26.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </a>
      </li>

      <li class="peek-item" data-rv>
        <a class="peek-row" href="#empresas"
           data-peek-img="../../../showroom/demo/ph-7.webp"
           data-peek-alt="Reunião em uma sala de vidro">
          <span class="peek-num">05</span>
          <span class="peek-title">Empresas e sócios</span>
          <span class="pill peek-cat">Liquidez</span>
          <img class="peek-thumb" src="../../../showroom/demo/ph-7.webp" alt="" loading="lazy" decoding="async">
          <svg class="peek-arrow" width="34" height="12" viewBox="0 0 34 12" fill="none" aria-hidden="true" focusable="false">
            <path d="M0 6H32M32 6L26.5 1M32 6L26.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </a>
      </li>

      <li class="peek-item" data-rv>
        <a class="peek-row" href="#legado"
           data-peek-img="../../../showroom/demo/ph-1.webp"
           data-peek-alt="Casal em uma varanda com vista para um vilarejo">
          <span class="peek-num">06</span>
          <span class="peek-title">Filantropia e legado</span>
          <span class="pill peek-cat">Próxima geração</span>
          <img class="peek-thumb" src="../../../showroom/demo/ph-1.webp" alt="" loading="lazy" decoding="async">
          <svg class="peek-arrow" width="34" height="12" viewBox="0 0 34 12" fill="none" aria-hidden="true" focusable="false">
            <path d="M0 6H32M32 6L26.5 1M32 6L26.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </a>
      </li>

    </ul>
  </div>
</section>

estilo.css · 271 linhas · 11,3 KB

/* ==========================================================================
   LISTA COM IMAGEM FLUTUANTE — prefixo peek-
   Seis linhas grandes (número, título display, categoria e seta) e uma foto
   que acompanha o cursor com atraso, inclinando conforme a velocidade da mão.

   Tokens usados:
     --display --ease      tipografia do título e curva única do projeto
     --fg --fg-2           título aceso (linha ativa) e apagado (repouso)
     --fg-muted            número da linha
     --accent              número e seta da linha ativa
     --rule                filete entre as linhas e fundo da foto carregando
     --radius-media        canto da foto flutuante e da miniatura

   A SEÇÃO É SÓ .wrap (contrato §4). Nada aqui sangra: a lista vive na coluna
   e a foto é `position:fixed`, ou seja, mora na JANELA e não na coluna. Não
   há inset, não há margem negativa e não há 100vw.

   TRÊS CAMADAS NA FOTO, e a divisão é o componente inteiro:

     .peek-float      só POSIÇÃO. transform reescrito a cada quadro, sem
                      transition nenhuma.
     .peek-float-box  só ENTRADA. opacidade, blur e escala, com transição.
     .peek-float-img  só a TROCA. duas imagens em fusão cruzada.

   Por que separar. `transform` é UMA propriedade: uma transition nela
   alcançaria o translate escrito por quadro, e o navegador passaria a
   interpolar entre duas posições que já são interpolação — a foto ficaria
   atrás da mão em degraus de meio segundo, com a curva do --ease empilhada
   sobre o lerp do script. Também não dá para escapar disso com a propriedade
   `scale` individual: ela é aplicada ANTES do `transform` na conta da matriz
   (Transforms 2, §Individual Transform Properties), e o resultado é
   S·T — a translação inteira sai multiplicada por 0,9 enquanto a escala
   sobe, o que num deslocamento de 1200px desloca a foto 120px. Por isso a
   escala mora na caixa de dentro, onde não há o que escalar além dela mesma.
   ========================================================================== */

.peek{
  /* Largura da foto flutuante. A altura sai do aspect-ratio 3/4 — foto de
     retrato, que é o recorte dos placeholders e o que cabe ao lado de uma
     lista alta sem cobrir duas linhas de uma vez. */
  --peek-w:clamp(230px,19vw,310px);

  /* Deslocamento da foto EM RELAÇÃO AO CURSOR. O x é px; o y é porcentagem
     da própria foto, e -50% é o que a centra verticalmente na ponta do
     ponteiro. Trocar por 0 encosta o topo da foto no cursor. */
  --peek-dx:24px;
  --peek-dy:-50%;

  /* Margem mínima até a borda da janela, em px. Lida pelo script, que
     limita a posição antes de escrevê-la. Só px — o script não resolve
     porcentagem aqui. */
  --peek-edge:16px;

  /* Fração do vão que a foto vence por quadro a 60Hz. O script converte
     para a taxa real da tela, então 120Hz não anda o dobro. Perto de 1 a
     foto cola no cursor (e o efeito some); abaixo de .08 ela parece
     arrastada por um elástico. */
  --peek-lerp:.16;

  /* Inclinação máxima, em GRAUS. Lida pelo script. 0 desliga a inclinação
     sem tocar no resto. */
  --peek-tilt:8;

  --peek-row-pad:clamp(18px,2.6vw,30px);  /* respiro vertical da linha */
  --peek-dim:.35;                         /* opacidade das linhas não ativas */
  --peek-dur:.35s;                        /* troca de estado da linha */
}

/* ---------------------------------------------------------------- a lista */
/* O filete de cima é do item e o de baixo é da lista: assim a última linha
   fecha sem precisar de :last-child, e acrescentar um <li> não exige mexer
   em regra nenhuma. */
.peek-list{
  margin-top:clamp(34px,5vh,56px);
  border-bottom:1px solid var(--rule);
}
.peek-item{border-top:1px solid var(--rule)}

.peek-row{
  display:flex;align-items:center;
  gap:clamp(14px,2.2vw,30px);
  padding:var(--peek-row-pad) 0;
  transition:opacity var(--peek-dur) var(--ease);
}

/* .is-hovering diz "uma linha está em foco de atenção" — vem do ponteiro ou
   do teclado, e o script põe nos dois casos. O nome guarda a origem do
   padrão; o papel é mais largo que ele. */
.peek-list.is-hovering .peek-row{opacity:var(--peek-dim)}
.peek-list.is-hovering .peek-row.is-active{opacity:1}

.peek-num{
  flex:0 0 auto;
  font-size:11px;letter-spacing:.18em;
  color:var(--fg-muted);
  transition:color var(--peek-dur) var(--ease);
}
.peek-row.is-active .peek-num{color:var(--accent)}

/* O título nasce em --fg-2 e acende em --fg. Seis títulos em corpo grande
   e tinta cheia de uma vez fazem a dobra gritar; e sem o degrau não haveria
   de onde "acender". A opacidade das outras linhas é o segundo sinal, não o
   primeiro. */
.peek-title{
  flex:1 1 auto;min-width:0;
  font-family:var(--display);font-weight:700;
  font-size:clamp(26px,3.4vw,52px);
  line-height:1.08;letter-spacing:-.022em;
  color:var(--fg-2);
  transition:color var(--peek-dur) var(--ease);
}
.peek-row.is-active .peek-title{color:var(--fg)}

/* Reaproveita a .pill do core: forma, respiro e tracking vêm de lá e nada
   aqui os sobrescreve — só o comportamento de caixa dentro da linha. */
.peek-cat{flex:0 0 auto}

/* MINIATURA DO TOQUE. Ela é o desenho da versão sem ponteiro, não um
   fallback: onde há hover, quem mostra a foto é a camada flutuante, e as
   duas juntas seriam a mesma imagem duas vezes na mesma linha. */
.peek-thumb{
  flex:0 0 auto;
  width:64px;height:80px;
  object-fit:cover;
  border-radius:var(--radius-media);
  background:var(--rule);
}
@media(hover:hover){
  .peek-thumb{display:none}
}

.peek-arrow{
  flex:0 0 auto;
  color:var(--accent);
  opacity:0;transform:translateX(-10px);
  transition:opacity var(--peek-dur) var(--ease),transform var(--peek-dur) var(--ease);
}
.peek-row.is-active .peek-arrow{opacity:1;transform:none}
@media(prefers-reduced-motion:reduce){
  .peek-arrow{transform:none;transition:opacity var(--peek-dur) linear}
  .peek-row.is-active .peek-arrow{transform:none}
}

/* ================================================================ a foto */
/* CAMADA DE POSIÇÃO. Só transform, e nenhuma transition — ver o cabeçalho.

   position:fixed porque a referência é a JANELA: as coordenadas que chegam
   do ponteiro são clientX/clientY, que já são de janela. Com `absolute`
   seria preciso somar o scroll a cada quadro e a foto atrasaria durante a
   rolagem. O preço é conhecido: fixed vira absolute dentro de qualquer
   ancestral com transform, filter, perspective, contain:paint ou
   will-change:transform. Por isso o script pendura esta div como filha
   DIRETA da <section> — o core dá position:relative a toda seção e nada
   mais, então não há ancestral que a capture. Se alguém puser data-rv na
   seção, o filter:blur(14px) do reveal quebra o fixed até o .in chegar.

   will-change:transform mantém a foto numa camada própria do compositor: o
   transform muda a cada quadro, que é exatamente o caso para o qual a
   propriedade existe. O custo é uma textura de ~310×413 (≈510 KB de memória
   de GPU) enquanto a dobra existe. Foi medido como o preço mais barato dos
   dois: sem ela, cada quadro repinta a foto.

   pointer-events:none é obrigatório: uma foto de 310px seguindo o cursor
   ficaria SOB o ponteiro o tempo todo, roubaria o hover da linha e a lista
   piscaria entre ativa e inativa a cada quadro. */
.peek-float{
  position:fixed;left:0;top:0;z-index:5;
  pointer-events:none;
  width:var(--peek-w,300px);
  aspect-ratio:3/4;
  will-change:transform;
  transform:translate3d(var(--peek-x,0px),var(--peek-y,0px),0)
            translate(var(--peek-dx,24px),var(--peek-dy,-50%))
            rotate(var(--peek-rot,0deg));
}

/* CAMADA DE ENTRADA. Nada entra seco (§5): opacidade, blur e escala juntos.
   O blur só corre nos 0,5s de entrada e de saída — em regime, a foto está
   em blur(0) e o custo por quadro é o de um transform puro.
   visibility tira a caixa da pintura em repouso e volta sem atraso na
   entrada; é o que garante que uma foto invisível não custe nada enquanto
   ninguém passa o mouse na dobra. */
.peek-float-box{
  position:absolute;inset:0;
  border-radius:var(--radius-media);
  overflow:hidden;
  background:var(--rule);
  opacity:0;visibility:hidden;
  filter:blur(14px);
  transform:scale(.9);
  transition:opacity .5s var(--ease),filter .5s var(--ease),
             transform .5s var(--ease),visibility 0s linear .5s;
}
.peek-float.is-on .peek-float-box{
  opacity:1;visibility:visible;
  filter:blur(0);
  transform:none;
  transition:opacity .5s var(--ease),filter .5s var(--ease),
             transform .5s var(--ease),visibility 0s linear 0s;
}
/* Com movimento reduzido a foto não escala nem desfoca: sobra a opacidade,
   que não é deslocamento. O script já a ancora na linha em vez de fazê-la
   perseguir a mão — ver o gotcha da ficha. */
@media(prefers-reduced-motion:reduce){
  .peek-float-box{
    filter:none;transform:none;
    transition:opacity .2s linear,visibility 0s linear .2s;
  }
  .peek-float.is-on .peek-float-box{
    filter:none;transform:none;
    transition:opacity .2s linear,visibility 0s linear 0s;
  }
}

/* DUAS IMAGENS, e não uma com o src trocado. Reescrever o src de um <img>
   já pintado apaga o quadro atual antes de o novo decodificar: a troca de
   linha pisca em branco, e piscada é o oposto do que a fusão quer dizer.
   Com duas, o script escreve o src na que está apagada, espera o load e só
   então troca qual das duas está acesa. */
.peek-float-img{
  position:absolute;inset:0;
  width:100%;height:100%;
  object-fit:cover;
  opacity:0;
  transition:opacity .28s var(--ease);
}
.peek-float-img.is-shown{opacity:1}
@media(prefers-reduced-motion:reduce){
  .peek-float-img{transition:none}
}

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não existe seguidor: o script sai no guard e a foto passa a
   ser a miniatura da linha, que aqui nasce visível. A seta some porque ela
   é sinal de hover, e sem hover ficaria acesa em nada. */
@media(hover:none){
  .peek-arrow{display:none}
  .peek-float{display:none}
  .peek-title{font-size:clamp(19px,4.4vw,26px)}
}

/* ---------------------------------------------------------------- mobile */
/* A linha vira grade de duas faixas: o número ocupa as duas, o título fica
   em cima e a categoria embaixo dele. Em flex, empurrar a categoria para a
   linha de baixo exigiria flex-basis:100% no título, o que também jogaria a
   miniatura para uma terceira linha. A grade resolve os três de uma vez. */
@media(max-width:760px){
  .peek-row{
    display:grid;
    grid-template-columns:auto minmax(0,1fr) auto;
    grid-template-areas:"num title media"
                        "num cat   media";
    align-items:center;
    column-gap:14px;row-gap:9px;
  }
  .peek-num{grid-area:num}
  .peek-title{grid-area:title;font-size:clamp(22px,6vw,30px)}
  .peek-cat{grid-area:cat;justify-self:start}
  /* MINIATURA E SETA DIVIDEM A MESMA CÉLULA. Uma coluna para cada parecia
     mais arrumado e cobrava caro: a que estivesse escondida deixava o
     column-gap dela para trás — 14px de vão vazio colado na borda direita,
     medido em 390px. Dividir a célula é seguro porque o feature `hover` vale
     sempre `hover` ou `none`, nunca os dois: onde a seta aparece, a
     miniatura está em display:none, e vice-versa. */
  .peek-thumb{grid-area:media}
  .peek-arrow{grid-area:media;justify-self:end}
}

@media(max-width:420px){
  .peek{--peek-row-pad:16px}
}

script.js · 390 linhas · 15,4 KB

/* ==========================================================================
   LISTA COM IMAGEM FLUTUANTE — a foto que segue a mão

   Faz cinco coisas, e nenhuma delas é obrigatória para a dobra funcionar:

     1. CRIA a camada flutuante (uma por lista) como filha direta da seção;
     2. SEGUE o ponteiro com atraso — lerp por quadro, convertido para a taxa
        real da tela, mais uma inclinação proporcional à velocidade em x;
     3. TROCA a foto ao mudar de linha, em fusão cruzada entre duas imagens;
     4. ANCORA a foto à direita da linha quando o foco chega pelo teclado;
     5. LIMITA a posição às bordas da janela.

   Um rAF só por lista, e ele dorme assim que a foto alcança o alvo e o
   ponteiro sai da lista. Nenhum listener de scroll próprio: a reancoragem
   do teclado assina o laço único do core (LIB.onScroll).

   GUARD DE ENTRADA: sem `(hover:hover) and (pointer:fine)` o script sai
   inteiro. Num aparelho de toque não há ponteiro para perseguir, o dedo
   cobriria a foto, e a versão que vale ali — a miniatura dentro da linha —
   é CSS puro e já está no markup.

   MOVIMENTO REDUZIDO: a foto não persegue nada. Ela pousa à direita da
   linha, exatamente como no teclado, e só se move quando a linha muda. A
   justificativa está na ficha: um seguidor "sem lerp" seria MAIS movimento,
   não menos — a foto saltaria de posição a cada evento de ponteiro, dezenas
   de vezes por segundo.
   ========================================================================== */

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

  /* Ganho da inclinação: graus por pixel andado em UM quadro de 60Hz. Com
     .04, o teto de 8° pede um passo de 200px por quadro — um golpe de mouse
     atravessando a tela. O movimento de leitura (10 a 40px por quadro) fica
     entre 0,4° e 1,6°, que é onde a inclinação se percebe sem virar
     cambalhota. */
  var GANHO = .04;
  var SEGUE_ROT = .34;  /* amortecimento da inclinação, por quadro de 60Hz */
  var PARADA = .3;      /* px: abaixo disso o laço considera que chegou */
  var TETO_DT = .05;    /* s: volta de aba em segundo plano não salta */

  /* 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;
  }

  /* Lê px ou porcentagem de uma custom property. A porcentagem é a mesma
     que o translate() do CSS entende: fração da própria caixa. Só estas
     duas unidades — calc() e em não passam por aqui, e a ficha diz isso. */
  function medida(txt, base) {
    txt = (txt || '').replace(/^\s+|\s+$/g, '');
    var n = parseFloat(txt);
    if (isNaN(n)) return 0;
    return txt.indexOf('%') > -1 ? base * n / 100 : n;
  }

  function limitar(v, min, max) {
    return v < min ? min : (v > max ? max : v);
  }

  function iniciar(raiz) {
    var lista = raiz.querySelector('[data-peek-list]');
    if (!lista) return;

    var linhas = lista.querySelectorAll('.peek-row');
    if (!linhas.length) return;

    /* Sem ponteiro fino, a dobra inteira é o CSS. Sair aqui é o que faz o
       celular não pagar por nada disto. */
    if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;

    var reduz = reduzido();
    var segue = !reduz;

    /* ------------------------------------------------------------ camada */
    /* Filha DIRETA da seção, nunca do .wrap e nunca de um [data-rv]: fixed
       dentro de ancestral com transform ou filter vira absolute, e todo
       [data-rv] carrega filter:blur(14px) até receber .in. */
    var flutuante = document.createElement('div');
    flutuante.className = 'peek-float';
    flutuante.setAttribute('aria-hidden', 'true');

    var caixa = document.createElement('div');
    caixa.className = 'peek-float-box';

    /* As DUAS nascem com a foto da primeira linha, e nenhuma delas acesa.
       Não é enfeite: um <img> sem src fica com naturalWidth 0 para sempre, e
       o primeiro hover teria de esperar um load para acender qualquer coisa.
       Assim a linha 01 — a mais provável de ser a primeira visitada — abre
       instantânea, porque trocar() reconhece que o src pedido já está lá. */
    var primeira = linhas[0].getAttribute('data-peek-img') || '';
    var imgs = [];
    for (var i = 0; i < 2; i++) {
      var im = document.createElement('img');
      im.className = 'peek-float-img';
      im.setAttribute('alt', '');
      im.setAttribute('decoding', 'async');
      if (primeira) im.setAttribute('src', primeira);
      caixa.appendChild(im);
      imgs.push(im);
    }
    flutuante.appendChild(caixa);
    raiz.appendChild(flutuante);

    /* ------------------------------------------------------------ estado */
    var w = 0, h = 0;        /* tamanho da foto */
    var dx = 0, dy = 0;      /* deslocamento em relação ao cursor, em px */
    var borda = 16;          /* margem até as bordas da janela */
    var lerp = .16, tilt = 8;

    var alvoX = 0, alvoY = 0, x = 0, y = 0, rot = 0;
    var laco = 0, antes = 0;
    var sobre = false;       /* ponteiro dentro da lista */
    var linhaAtiva = null;   /* linha acesa (ponteiro OU foco) */
    var linhaFoco = null;    /* linha com foco de teclado */
    var soltarScroll = null; /* assinatura do laço de scroll do core */
    var vez = 0;             /* qual das duas imagens está acesa */
    var srcAtual = '';

    /* ------------------------------------------------------------ medidas */
    /* Uma leitura só, agrupada, fora de qualquer listener de movimento. */
    function remedir() {
      var cs = getComputedStyle(flutuante);
      w = flutuante.offsetWidth;
      h = flutuante.offsetHeight;
      dx = medida(cs.getPropertyValue('--peek-dx'), w);
      dy = medida(cs.getPropertyValue('--peek-dy'), h);
      borda = medida(cs.getPropertyValue('--peek-edge'), 0) || 16;
      lerp = parseFloat(cs.getPropertyValue('--peek-lerp'));
      if (isNaN(lerp) || lerp <= 0 || lerp > 1) lerp = .16;
      tilt = parseFloat(cs.getPropertyValue('--peek-tilt'));
      if (isNaN(tilt) || tilt < 0) tilt = 8;
    }

    /* clientWidth do <html>, não innerWidth: innerWidth inclui a barra de
       rolagem, e a foto acabaria meio pixel debaixo dela na borda direita.
       O --peek-x escrito é o canto da caixa ANTES do deslocamento do CSS,
       por isso o limite desconta dx/dy. */
    function limitarX(v) {
      var doc = document.documentElement;
      return limitar(v, borda - dx, doc.clientWidth - w - borda - dx);
    }
    function limitarY(v) {
      var doc = document.documentElement;
      return limitar(v, borda - dy, doc.clientHeight - h - borda - dy);
    }

    function escrever() {
      flutuante.style.setProperty('--peek-x', Math.round(x * 10) / 10 + 'px');
      flutuante.style.setProperty('--peek-y', Math.round(y * 10) / 10 + 'px');
      flutuante.style.setProperty('--peek-rot', Math.round(rot * 100) / 100 + 'deg');
    }

    /* ------------------------------------------------------------ fusão */
    /* Escreve na imagem apagada e só troca qual está acesa quando ela
       terminar de carregar. Trocar o src da imagem VISÍVEL apagaria o
       quadro atual antes de o novo existir, e a mudança de linha piscaria
       em branco. O feito/srcAtual protegem de duas trocas rápidas: quem
       chegou primeiro não acende depois de quem chegou por último. */
    function trocar(src, alt) {
      if (!src || src === srcAtual) return;
      srcAtual = src;

      var proxima = imgs[1 - vez];
      var feito = false;

      function mostrar() {
        if (feito || srcAtual !== src) return;
        feito = true;
        proxima.setAttribute('alt', alt || '');
        imgs[vez].classList.remove('is-shown');
        proxima.classList.add('is-shown');
        vez = 1 - vez;
      }

      if (proxima.getAttribute('src') === src) { mostrar(); return; }
      proxima.onload = mostrar;
      proxima.setAttribute('src', src);
      if (proxima.complete && proxima.naturalWidth) mostrar();
    }

    /* ------------------------------------------------------------ linhas */
    function ativar(linha) {
      if (linhaAtiva === linha) return;
      if (linhaAtiva) linhaAtiva.classList.remove('is-active');
      linhaAtiva = linha;
      linha.classList.add('is-active');
      lista.classList.add('is-hovering');
      flutuante.classList.add('is-on');
      trocar(linha.getAttribute('data-peek-img'), linha.getAttribute('data-peek-alt'));
    }

    function apagar() {
      if (linhaAtiva) linhaAtiva.classList.remove('is-active');
      linhaAtiva = null;
      lista.classList.remove('is-hovering');
      flutuante.classList.remove('is-on');
    }

    function talvezApagar() {
      if (!sobre && !linhaFoco) apagar();
    }

    function linhaDe(no) {
      while (no && no !== lista) {
        if (no.classList && no.classList.contains('peek-row')) return no;
        no = no.parentNode;
      }
      return null;
    }

    /* Pousa a foto encostada na borda direita da linha, centrada nela.
       Serve ao teclado e ao movimento reduzido — a mesma conta, um caminho
       de código só. */
    function ancorar(linha) {
      if (!linha) return;
      var r = linha.getBoundingClientRect();
      x = limitarX(r.right - w - dx);
      y = limitarY(r.top + r.height / 2 - h / 2 - dy);
      rot = 0;
      escrever();
    }

    function reancorar() {
      if (linhaFoco) ancorar(linhaFoco);
    }

    /* ------------------------------------------------------------ laço */
    function passo(agora) {
      var dt = antes ? Math.min((agora - antes) / 1000, TETO_DT) : 1 / 60;
      antes = agora;

      /* Quantos quadros de 60Hz couberam neste quadro real. É o que faz
         120Hz não convergir na metade do tempo — mesma disciplina do
         --pfl-speed em px/s do midia/carrossel-perfis. */
      var quadros = dt * 60;
      var k = 1 - Math.pow(1 - lerp, quadros);

      var xAntes = x;
      x += (alvoX - x) * k;
      y += (alvoY - y) * k;

      /* Velocidade normalizada para "px por quadro de 60Hz", e daí para
         graus. A inclinação decai sozinha porque o alvo dela vira zero
         assim que a mão para.

         A inclinação tem amortecimento PRÓPRIO, e mais solto que o da
         posição (.34 contra .16): ligada direto à velocidade ela treme, e
         amortecida junto com a posição ela chega tarde — a foto já parou e
         ainda está torta. O limite entra depois do amortecimento, e não só
         no alvo: assim --peek-tilt é teto do que se VÊ. */
      var vx = (x - xAntes) / quadros;
      var alvoRot = limitar(vx * GANHO, -tilt, tilt);
      rot += (alvoRot - rot) * (1 - Math.pow(1 - SEGUE_ROT, quadros));
      rot = limitar(rot, -tilt, tilt);

      escrever();

      var falta = Math.abs(alvoX - x) > PARADA || Math.abs(alvoY - y) > PARADA ||
                  Math.abs(rot) > .05;
      if (sobre || falta) {
        laco = requestAnimationFrame(passo);
      } else {
        laco = 0;
        antes = 0;
      }
    }

    function ligar() {
      if (laco) return;
      antes = 0;
      laco = requestAnimationFrame(passo);
    }

    /* ------------------------------------------------------------ ponteiro */
    function mirar(e) {
      alvoX = limitarX(e.clientX);
      alvoY = limitarY(e.clientY);
    }

    lista.addEventListener('pointerenter', function (e) {
      /* Aparelho híbrido (notebook com tela sensível) casa hover:hover e
         mesmo assim entrega eventos de toque. O seguidor é do mouse. */
      if (e.pointerType === 'touch') return;
      sobre = true;
      if (!segue) return;
      mirar(e);
      /* Encaixe seco na entrada: sem isto a foto atravessaria a tela vindo
         da última posição em que ficou, e o efeito abriria com um voo. */
      x = alvoX; y = alvoY; rot = 0;
      escrever();
      ligar();
    });

    lista.addEventListener('pointermove', function (e) {
      if (e.pointerType === 'touch' || !segue) return;
      sobre = true;
      mirar(e);
      ligar();
    }, { passive: true });

    lista.addEventListener('pointerleave', function (e) {
      if (e.pointerType === 'touch') return;
      sobre = false;
      talvezApagar();
      ligar();          /* um último quadro para a inclinação voltar a zero */
    });

    for (var n = 0; n < linhas.length; n++) {
      (function (linha) {
        linha.addEventListener('pointerenter', function (e) {
          if (e.pointerType === 'touch') return;
          ativar(linha);
          /* Com movimento reduzido a foto não persegue: ela pousa na linha. */
          if (!segue) ancorar(linha);
        });
      })(linhas[n]);
    }

    /* ------------------------------------------------------------ teclado */
    /* O navegador rola a página para trazer o link focado à vista DEPOIS de
       disparar o focusin — medido, não suposto. Medir a linha dentro do
       próprio evento devolveria a posição de antes da rolagem, e a foto
       pousaria fora da linha. Daí o rAF.

       Nada aqui move o foco, então não há focus({preventScroll:true}) a
       fazer. Quem acrescentar setas para andar de linha em linha precisa
       das duas coisas: preventScroll na chamada e este mesmo rAF depois. */
    lista.addEventListener('focusin', function (e) {
      var linha = linhaDe(e.target);
      if (!linha) return;
      linhaFoco = linha;
      ativar(linha);
      requestAnimationFrame(reancorar);
      /* Rolar a página com uma linha focada moveria a linha e deixaria a
         foto para trás — ela é fixed. Assinar o laço único do core resolve
         sem listener de scroll próprio (contrato §7.4). */
      if (!soltarScroll && window.LIB && window.LIB.onScroll) {
        soltarScroll = window.LIB.onScroll(reancorar);
      }
    });

    lista.addEventListener('focusout', function (e) {
      if (e.relatedTarget && lista.contains(e.relatedTarget)) return;
      linhaFoco = null;
      if (soltarScroll) { soltarScroll(); soltarScroll = null; }
      talvezApagar();
    });

    /* ------------------------------------------------------------ tela */
    var pedido = 0;
    addEventListener('resize', function () {
      if (pedido) return;
      /* Uma medida por quadro: remedir() lê layout, e uma leitura por evento
         de resize trava a janela ao redimensionar. */
      pedido = requestAnimationFrame(function () {
        pedido = 0;
        remedir();
        alvoX = limitarX(alvoX);
        alvoY = limitarY(alvoY);
        if (linhaFoco) ancorar(linhaFoco);
        else if (!segue && linhaAtiva) ancorar(linhaAtiva);
        else ligar();
      });
    }, { passive: true });

    /* ------------------------------------------------------------ preparo */
    /* As seis fotos entram em cache antes do primeiro hover: sem isto, a
       primeira linha visitada abre com a caixa vazia enquanto a imagem
       baixa. Depois do load da página, para não disputar banda com o que
       está acima da dobra. */
    function preparar() {
      for (var i = 0; i < linhas.length; i++) {
        var src = linhas[i].getAttribute('data-peek-img');
        if (src) { var pre = new Image(); pre.src = src; }
      }
    }
    if (document.readyState === 'complete') preparar();
    else addEventListener('load', preparar, { once: true });

    remedir();
    escrever();
  }

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

Seis linhas grandes atravessando a coluna, separadas por filete: número, título em display, categoria em pílula e uma seta. Ao passar o ponteiro, a linha sob a mão acende e as outras esmaecem para 35%; uma foto de retrato aparece desfocada, entra em foco e passa a acompanhar o cursor com atraso, inclinando-se de leve conforme a velocidade da mão. Trocar de linha troca a foto em fusão cruzada, sem piscar. Sair da lista devolve tudo ao repouso.

Resolve a dobra de "o que fazemos" quando existem seis frentes de trabalho e uma foto boa para cada uma, mas nenhuma delas merece um card. A grade de cards obriga a decidir o tamanho da foto antes de saber quantos itens existem, e seis cards de foto viram uma parede de imagem que compete com o título da seção. Aqui a lista lê como índice — texto, hierarquia, ordem — e a imagem só existe enquanto alguém está olhando para uma linha específica. É um carrossel de imagens dirigido pela mão, sem carrossel: não há seta, não há indicador e não há estado a navegar.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho Uma ou duas palavras. Categoria da dobra, não frase.
Título Até 40 caracteres, com uma palavra em <em>. O .ttl do core cuida da entrada.
Apoio Duas linhas, até 160 caracteres. Explica o critério da lista, não a lista.
Número Dois dígitos com zero à esquerda (01). É régua visual, não ranking — não reordene por importância depois de publicar.
Título da linha 14 a 26 caracteres. Acima disso o título encosta na pílula em 1280px e quebra em duas linhas em 1440px, desalinhando a fileira.
Categoria Uma ou duas palavras, na .pill do core. Todas do mesmo tipo: ou todas são público (Família, Empresas), ou todas são produto (Offshore, Previdência) — misturar os dois eixos faz a coluna da direita parecer aleatória.
Foto Uma por linha, retrato. Ver a especificação abaixo.

O que mantém a dobra coesa é o paralelismo gramatical dos títulos: se um começa por substantivo ("Gestão de carteira"), todos começam. Um verbo no meio de seis substantivos salta mais do que parece, porque os seis são lidos em sequência e em corpo grande.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/listas/lista-imagem-flutuante/estilo.css">
<script src="componentes/listas/lista-imagem-flutuante/script.js"></script>

Ganchos

Gancho Papel
[data-peek] Raiz. O script parte daqui e pendura a camada flutuante como filha direta dela.
[data-peek-list] O <ul> que escuta ponteiro e foco, e recebe .is-hovering.
data-peek-img Caminho da foto daquela linha. Vive no <a>, não num array do script.
data-peek-alt Descrição da foto. Vai para o alt da imagem flutuante.
--peek-w Largura da foto. clamp(230px,19vw,310px) no padrão; a altura sai do aspect-ratio:3/4.
--peek-dx / --peek-dy Deslocamento a partir do cursor. 24px e -50% no padrão — a foto fica à direita e centrada na ponta do ponteiro. O y aceita porcentagem da própria foto; o script resolve px e % e mais nada.
--peek-edge Margem mínima até a borda da janela, em px. 16px no padrão.
--peek-lerp Fração do vão vencida por quadro a 60Hz. .16 no padrão; o script converte para a taxa real da tela.
--peek-tilt Inclinação máxima, em graus. 8 no padrão — é teto de segurança, não o valor que se vê (ver gotchas). 0 desliga.
--peek-row-pad / --peek-dim / --peek-dur Respiro da linha, opacidade das linhas apagadas e duração da troca de estado.
--peek-x / --peek-y / --peek-rot Escritos pelo script a cada quadro. Não se ajustam à mão.
.is-hovering Na lista: uma linha está em foco de atenção. O script põe no hover e no foco de teclado.
.is-active Na linha acesa.
.is-on Na camada flutuante: foto visível.
.is-shown Na imagem da fusão que está acesa das duas.

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 — o "list image hover" dos portfólios premiados no Awwwards, os templates de índice de projeto em Framer e Webflow, as páginas de serviço de estúdio de design. Não há referência única: a forma é conhecida o bastante para ser desenhada de memória, e foi. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. A implementação corrente do padrão costuma vir com GSAP e um mousemove que escreve transform inline sem interpolação de tempo — aqui não entra nem uma coisa nem outra.

Qual irmão ele substitui, e em que caso (CLAUDE.md §11). Antes de construir, a busca no catálogo apontou listas/leque-paineis como o parente mais próximo: também é lista de serviços com foto e estado ativo. Ele resolve o caso em que cada item precisa de um parágrafo — o painel aberto mostra título e descrição, e a foto é o fundo do painel. Não resolve o caso desta dobra: seis painéis lado a lado espremem cada foto numa faixa de 90px, o título vira texto vertical e não sobra lugar para o número nem para a categoria. O leque é para quatro a seis serviços com explicação; esta lista é para seis frentes que se anunciam pelo nome e cuja foto é contexto, não conteúdo. Os outros dois vizinhos foram descartados por motivo mais simples: listas/acordeao-faq não tem imagem, e midia/galeria-scroll-horizontal inverte a hierarquia — lá a foto é o item e o texto é legenda.

Decisões de tradução para o contrato. Cor virou token: o título em repouso lê --fg-2 e acende em --fg, o número e a seta acendem em --accent, o filete é --rule, o canto da foto é --radius-media — a mesma dobra roda em .sec-deep e em .sec-paper sem tocar numa linha do componente. Os parafusos do efeito viraram variáveis do prefixo (--peek-w, --peek-dx/--peek-dy, --peek-lerp, --peek-tilt, --peek-edge) declaradas na raiz e lidas do CSS pelo script, para que ajustar o movimento seja mexer no estilo e não no código. A entrada da foto carrega opacidade, desfoque e escala como o §5 manda, e a posição ficou deliberadamente fora de qualquer transition. O lerp passou a ser calculado sobre o tempo do quadro, pela mesma disciplina do --pfl-speed em px/s do midia/carrossel-perfis. A entrada das linhas usa data-rv/data-d e .ttl do core, sem observer próprio.

E o padrão ganhou três coisas que a versão comum não tem. Teclado: chegar por Tab acende a linha e faz a foto pousar à direita dela, com reancoragem se a página rolar — na versão comum, quem navega por teclado nunca vê a imagem. Toque: cada linha traz a própria miniatura, e o script nem chega a rodar; a versão comum ou deixa a foto fora do celular ou a faz perseguir um dedo que não existe. Movimento reduzido: a foto não persegue nada — pousa na linha, pelo mesmo caminho de código do teclado. A alternativa óbvia (seguir o cursor "sem lerp") foi descartada porque seria mais movimento, não menos: a foto passaria a teleportar de posição a cada evento de ponteiro, dezenas de vezes por segundo, que é exatamente o que a preferência existe para suprimir.