← Biblioteca componentes/midia/carrossel-perfis/ficha.md CódigoTela cheia
midia/carrossel-perfis

Carrossel de perfis arrastável

🟢 estavel complexidade alta prefixo pfl- carrossel arrasto drag loop-infinito equipe socios perfil bio hover marquee esteira sangria

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

<link rel="stylesheet" href="componentes/midia/carrossel-perfis/estilo.css">
<script src="componentes/midia/carrossel-perfis/script.js"></script>

markup.html · 185 linhas · 8,7 KB

<!-- ==========================================================================
     CARROSSEL DE PERFIS — trilho arrastável de pessoas, com esteira embaixo
     Prefixo: pfl-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. O laço infinito, o arrasto e a inércia são ~150 linhas de vanilla.

     TRÊS PEÇAS NUMA DOBRA SÓ:
       1. coluna de texto à esquerda, com título display gigante;
       2. trilho de cards que anda sozinho, sangra pela direita e responde ao
          arrasto do mouse ou do dedo;
       3. esteira de texto no rodapé da seção, em tinta fraca, que é o chão
          da dobra e ao mesmo tempo o respiro para a seção seguinte.

     SANGRIA (contrato §4). Não há .wrap aqui. Quem alinha a coluna de texto
     à margem da página é --pfl-inset no estilo.css, que refaz a conta do
     .wrap aplicada ao grid — mesmo recurso do midia/galeria-scroll-horizontal.
     O trilho ocupa a segunda coluna do grid e vaza para fora dela; quem corta
     é o overflow:hidden da seção. Nenhuma margem negativa, nenhum 100vw.

     CLONES. O markup traz os perfis UMA vez. O script duplica a fila até dar
     volta e marca as cópias com aria-hidden — quem usa leitor de tela ouve
     quatro pessoas, não doze. Escrever as cópias à mão significaria manter a
     bio de cada sócio em três lugares.

     O CARD É FOCÁVEL de propósito (tabindex="0"): a bio aparece no hover, e
     sem isso quem navega por teclado nunca chegaria nela. A bio já está no
     DOM em qualquer caso — o painel é tratamento visual, não conteúdo
     escondido. No toque, um toque abre e outro fecha.
     ========================================================================== -->

<section class="sec-paper pfl" data-pfl>

  <div class="pfl-grid">

    <div class="pfl-head">
      <h2 class="display-xl ttl">Os sócios</h2>

      <div class="pfl-body">
        <p class="pfl-p" data-rv>
          Aqui os sócios não representam a instituição — eles sentam do seu
          lado da mesa.
        </p>
        <p class="pfl-p" data-rv data-d="1">
          Cada um responde por uma frente da casa — carteira, sucessão,
          proteção, estruturas no exterior — e toda recomendação passa pelo
          comitê antes de chegar à sua política de investimentos. Nenhum deles
          é remunerado por produto distribuído: a receita da casa é a taxa
          previamente acordada com a família, e só ela.
        </p>
      </div>
    </div>

    <div class="pfl-rail">
      <ul class="pfl-track" data-pfl-track tabindex="0"
          aria-label="Sócios — arraste para o lado ou use as setas do teclado">

        <li class="pfl-item" data-rv>
          <article class="pfl-card" tabindex="0" style="--pfl-focus:50% 30%">
            <div class="pfl-photo">
              <img class="pfl-img" src="../../../showroom/demo/ph-4.webp"
                   alt="Retrato de Helena Vasques" decoding="async">
              <div class="pfl-bio">
                <p class="pfl-bio-text">
                  Helena é economista de formação, com certificação de
                  planejadora financeira. Passou por mesa de private e depois
                  pela área de planejamento de uma gestora, até fundar a casa
                  com a tese de que assessorar e distribuir produto não cabem
                  na mesma empresa. Responde pelo wealth planning: traduz o
                  que a família quer da vida em política de investimentos
                  escrita, com horizonte, limite de risco e regra de saque. É
                  ela quem conduz o primeiro diagnóstico de cada cliente.
                </p>
              </div>
            </div>
            <span class="pfl-rule"></span>
            <div class="pfl-foot">
              <h3 class="pfl-name">Helena Vasques</h3>
              <p class="pfl-role">Fundadora · Wealth planning</p>
            </div>
          </article>
        </li>

        <li class="pfl-item" data-rv data-d="1">
          <article class="pfl-card" tabindex="0" style="--pfl-focus:50% 40%">
            <div class="pfl-photo">
              <img class="pfl-img" src="../../../showroom/demo/ph-7.webp"
                   alt="Retrato de Bruno Sarmento" loading="lazy" decoding="async">
              <div class="pfl-bio">
                <p class="pfl-bio-text">
                  Bruno é engenheiro de formação, com mestrado em finanças.
                  Passou por mesa de renda variável e depois por uma gestora
                  independente, onde cuidou da alocação em fundos
                  multimercado. Responde pela estratégia de carteira: define
                  as classes de ativo, os pesos e as bandas de rebalanceamento
                  que o comitê revisa a cada trimestre. É quem explica, na
                  reunião, por que a carteira não seguiu a manchete do mês —
                  e por que ela não foi feita para seguir.
                </p>
              </div>
            </div>
            <span class="pfl-rule"></span>
            <div class="pfl-foot">
              <h3 class="pfl-name">Bruno Sarmento</h3>
              <p class="pfl-role">Sócio · Estratégia de carteira</p>
            </div>
          </article>
        </li>

        <li class="pfl-item" data-rv data-d="2">
          <article class="pfl-card" tabindex="0" style="--pfl-focus:58% 12%">
            <div class="pfl-photo">
              <img class="pfl-img" src="../../../showroom/demo/ph-3.webp"
                   alt="Retrato de Clara Bittencourt" loading="lazy" decoding="async">
              <div class="pfl-bio">
                <p class="pfl-bio-text">
                  Clara é advogada de formação, com especialização em direito
                  de família e sucessões. Passou por escritório jurídico que
                  atendia grupos familiares e depois pelo wealth planning de
                  uma gestora, montando holdings e acordos de sócios.
                  Responde pelo planejamento sucessório: desenha a holding, a
                  doação em vida, o testamento e o seguro que paga o imposto
                  na hora certa. É a sócia que faz as perguntas incômodas na
                  primeira reunião — e a que continua atendendo o telefone
                  três anos depois, quando a resposta finalmente importa.
                </p>
              </div>
            </div>
            <span class="pfl-rule"></span>
            <div class="pfl-foot">
              <h3 class="pfl-name">Clara Bittencourt</h3>
              <p class="pfl-role">Sócia · Planejamento sucessório</p>
            </div>
          </article>
        </li>

        <li class="pfl-item" data-rv data-d="3">
          <article class="pfl-card" tabindex="0" style="--pfl-focus:50% 34%">
            <div class="pfl-photo">
              <img class="pfl-img" src="../../../showroom/demo/ph-5.webp"
                   alt="Retrato de Otávio Rangel" loading="lazy" decoding="async">
              <div class="pfl-bio">
                <p class="pfl-bio-text">
                  Otávio é contador de formação, com pós-graduação em
                  tributação internacional. Passou por mesa de câmbio e depois
                  por uma gestora com veículo no exterior, onde cuidou de
                  reporte e enquadramento fiscal. Responde pelas estruturas
                  internacionais: a conta lá fora, o fundo, o trust quando
                  faz sentido e a declaração que mantém tudo isso em ordem
                  aqui. É quem diz não quando a estrutura custa mais do que
                  resolve.
                </p>
              </div>
            </div>
            <span class="pfl-rule"></span>
            <div class="pfl-foot">
              <h3 class="pfl-name">Otávio Rangel</h3>
              <p class="pfl-role">Sócio · Estruturas internacionais</p>
            </div>
          </article>
        </li>

      </ul>
    </div>

  </div>

  <!-- ESTEIRA. Puramente gráfica: aria-hidden, sem seleção e sem ponteiro.
       Dois conjuntos idênticos no trilho, que anda -50% — exatamente a
       largura de um deles. É a mesma receita do prova/marquee-logos. -->
  <div class="pfl-strip" aria-hidden="true">
    <div class="pfl-strip-rail">
      <div class="pfl-strip-set">
        <span class="pfl-strip-word">Liberta Wealth</span>
        <span class="pfl-strip-word">Liberta Wealth</span>
        <span class="pfl-strip-word">Liberta Wealth</span>
      </div>
      <div class="pfl-strip-set">
        <span class="pfl-strip-word">Liberta Wealth</span>
        <span class="pfl-strip-word">Liberta Wealth</span>
        <span class="pfl-strip-word">Liberta Wealth</span>
      </div>
    </div>
  </div>

</section>

estilo.css · 296 linhas · 12,4 KB

/* ==========================================================================
   CARROSSEL DE PERFIS — prefixo pfl-
   Coluna de texto à esquerda, trilho de cards de pessoa que anda sozinho e
   sangra pela direita, esteira de texto no chão da seção.

   Tokens usados:
     --pad --max          conta do recuo que alinha a coluna de texto à página
     --display --ease     tipografia e curva do projeto
     --sec-gap            respiro vertical da dobra
     --accent --dark      painel da bio: fundo de marca, texto escuro
     --fg --fg-2 --rule   título, corpo e filete, herdados do escopo de tom
     --radius-media       canto da foto

   A BIO É UMA ILHA DE MARCA. O painel que cobre a foto no hover pinta
   --accent e escreve em --dark, e não em --fg: assim ele lê igual numa
   seção clara e numa escura. O que está fora do painel (nome, cargo,
   filete) continua lendo o escopo de tom da seção.
   ========================================================================== */

.pfl{
  /* RECUO DA COLUNA (contrato §4). Não existe .wrap nesta dobra: o trilho
     precisa vazar para a direita, e um .wrap o prenderia. Esta variável
     refaz a conta do .wrap — metade da sobra mais o respiro — e é aplicada
     ao grid, não à página. O 100% é a largura da SEÇÃO.
       tela estreita ->  a coluna já encosta nas bordas, vale --pad
       tela larga    ->  (100% - --max) / 2 + --pad */
  --pfl-inset:max(var(--pad),calc((100% - var(--max)) / 2 + var(--pad)));

  --pfl-col:clamp(280px,32vw,468px);     /* coluna de texto */
  --pfl-gap:clamp(32px,5.5vw,80px);      /* vão entre texto e trilho */
  --pfl-card:clamp(258px,33vw,485px);    /* largura do card */
  --pfl-space:clamp(20px,4.5vw,65px);    /* vão entre um card e o próximo */

  /* Corpo do título. Mesmo teto e mesmo motivo do
     conteudo/apresentacao-foto-alternada: o original roda 160px numa display
     condensada, e uma display de largura normal estoura a coluna nesse corpo. */
  --pfl-title:clamp(46px,6vw,104px);
  --display-xl:var(--pfl-title);

  /* Velocidade da esteira de cards, em PIXELS POR SEGUNDO. Lida pelo script,
     que faz a conta por tempo e não por quadro — assim a dobra anda igual em
     tela de 60Hz e de 120Hz. */
  --pfl-speed:26;

  --pfl-strip-size:clamp(46px,10vw,150px);   /* corpo da esteira de texto */
  --pfl-strip-gap:clamp(24px,3.4vw,50px);
  --pfl-strip-dur:38s;

  /* Chão da seção. A esteira ocupa o lugar do respiro de baixo: ela é a
     última coisa da dobra e encosta na seção seguinte, que é o efeito.
     Numa página SEM a esteira, ponha --pfl-floor:var(--sec-gap) e o respiro
     volta ao normal. */
  --pfl-floor:0px;

  position:relative;
  /* O trilho vaza para fora da segunda coluna do grid; quem corta é aqui. */
  overflow:hidden;
  /* Não usa .sec-pad porque o rodapé desta dobra não é vazio — ver
     --pfl-floor acima. O topo continua lendo --sec-gap, que é a fonte
     única do ritmo entre dobras (§4). */
  padding-block:var(--sec-gap) var(--pfl-floor);
}

.pfl-grid{
  display:grid;
  grid-template-columns:minmax(0,var(--pfl-col)) minmax(0,1fr);
  gap:var(--pfl-gap);
  align-items:center;
  /* Único padding-inline do componente, e ele é do GRID, não da página:
     alinha a coluna de texto à margem sem prender o trilho. Mesma solução
     do midia/galeria-scroll-horizontal. */
  padding-inline-start:var(--pfl-inset);
}

/* ---------------------------------------------------------------- coluna de texto */
.pfl-head{display:flex;flex-direction:column;gap:clamp(22px,3vw,32px);min-width:0}

/* O título é a primitiva .display-xl do core (ver core/base.css). Era a
   segunda das três cópias que dispararam a promoção do §6. */

.pfl-body{display:flex;flex-direction:column;gap:20px}
.pfl-p{font-size:16px;line-height:1.5;color:var(--fg-2)}

/* ---------------------------------------------------------------- trilho */
/* min-width:0 é o que permite a coluna encolher abaixo do conteúdo — sem
   ele, o grid dá ao trilho a largura da fila inteira e a coluna de texto
   some.

   O overflow:hidden aqui é OBRIGATÓRIO, e não é o mesmo da seção. O trilho
   anda para a esquerda: sem este recorte, o card que sai de cena continua
   desenhado e passa POR CIMA do título e dos parágrafos. Quem corta pela
   direita continua sendo a seção — esta coluna do grid vai até a borda da
   página, então o recorte da direita cai exatamente no mesmo lugar.

   O padding-block é o preço do recorte: overflow:hidden corta nos dois
   eixos, e sem esses 6px o anel de foco do card — que o core desenha 3px
   para fora da caixa — sumiria em cima e embaixo. */
.pfl-rail{min-width:0;overflow:hidden;padding-block:6px}

.pfl-track{
  display:flex;
  gap:var(--pfl-space);
  width:max-content;
  cursor:grab;
  /* O gesto horizontal é nosso; o vertical continua sendo da página. Sem
     isto, arrastar de lado no celular trava a rolagem. */
  touch-action:pan-y;
  /* Arrastar sobre texto e foto selecionaria os dois. */
  user-select:none;-webkit-user-select:none;
  will-change:transform;
}
.pfl-track.is-dragging{cursor:grabbing}
/* O trilho é focável (tabindex no markup) para as setas funcionarem. O anel
   do core desenha 3px para fora; sem este respiro, a seção o cortaria. */
.pfl-track:focus-visible{outline-offset:-2px}

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

/* ---------------------------------------------------------------- card */
.pfl-card{
  display:flex;flex-direction:column;
  gap:clamp(20px,3vw,40px);
  /* O card não tem fundo nem borda: ele é foto, filete e duas linhas sobre
     o papel da seção. Qualquer caixa aqui brigaria com a esteira do rodapé,
     que é justamente o que dá corpo à dobra. */
}
/* O card é focável (tabindex no markup) e o anel do core fica: chegar por
   teclado abre a bio, mas sem o anel não há como saber ONDE se está na
   fileira. Clicar com o mouse não acende nada — :focus-visible só dispara
   para foco por teclado. */

/* 1.08 é a proporção do original (485 × 449): quase quadrado, um fio mais
   largo que alto. Retrato fechado dentro de caixa larga é o enquadramento
   que faz a fileira de rostos ler como fileira, e não como cartas. */
.pfl-photo{
  position:relative;
  aspect-ratio:1.08;
  overflow:hidden;
  border-radius:var(--radius-media);
  background:var(--rule);
}

/* O corte é por card, não por regra: retrato de estúdio tem o rosto no terço
   de cima, foto de ambiente tem no meio, e cravar um valor único faria uma
   das duas famílias sair decapitada. Cada card declara o seu no markup
   (style="--pfl-focus:50% 30%"), como no conteudo/pilares-texto-na-foto —
   o que também impede o acoplamento entre a ORDEM dos cards e o CSS. */
.pfl-img{
  position:absolute;inset:0;
  width:100%;height:100%;object-fit:cover;
  object-position:var(--pfl-focus,50% 32%);
}
/* HOVER NÃO MEXE NA IMAGEM (CLAUDE.md §6, regra de 2026-09-29). O zoom de
   foto sob o ponteiro saiu: escala e deslocamento de mídia no hover mexem
   em recorte e composição, e é o que quebra a página quando o componente
   é colado noutro lugar. O retorno é o painel da bio, que cobre a
   foto em opacidade — ela mesma não se move. */


/* PAINEL DA BIO. Cobre a foto — não o card inteiro. No original ele cobre
   tudo e apaga o nome da pessoa justamente enquanto se lê a bio dela; aqui
   o nome e o cargo continuam embaixo, legíveis, e o painel entra só onde a
   frase "aparece mais texto na imagem" quer dizer alguma coisa.
   pointer-events:none em repouso: um painel invisível por cima da foto
   comeria o hover dela e o card nunca acenderia. */
/* O PAINEL SÓ ESMAECE — não sobe e não desfoca. Os dois efeitos foram
   testados nele e os dois abrem fresta escura sobre a foto:

     filter:blur() desfia as bordas do próprio elemento, então o retângulo
     sólido fica MENOR que a caixa enquanto o desfoque cai a zero, e a foto
     aparece num halo em volta;

     transform:translateY() desliza o painel para baixo dentro de uma caixa
     que corta (a .pfl-photo tem overflow:hidden), deixando à mostra uma
     tira da foto no topo durante toda a subida.

   Quem entra com blur e subida é o TEXTO, logo abaixo — ali o efeito é o
   que o §5 do contrato pede e não há borda para desfiar, porque o
   parágrafo tem o padding do painel inteiro em volta. */
.pfl-bio{
  position:absolute;inset:0;z-index:2;
  display:flex;align-items:flex-start;
  padding:clamp(18px,2.6vw,32px);
  background:var(--accent);
  color:var(--dark);
  opacity:0;pointer-events:none;
  transition:opacity .45s var(--ease);
}
.pfl-card:hover .pfl-bio,
.pfl-card:focus-visible .pfl-bio,
.pfl-card.is-open .pfl-bio{opacity:1}

/* 16px é o corpo do original, e é o teto: o painel tem a altura da foto e
   não rola. Quem escreve bio mais longa que o padrão editorial da ficha
   perde o fim dela, cortado pelo overflow da caixa.

   O atraso é o que dá ordem à cena: primeiro o painel cobre a foto, e só
   então a bio assenta. Simultâneos, os dois viram um borrão só. */
.pfl-bio-text{
  font-size:clamp(13px,1.2vw,16px);
  line-height:1.5;
  transform:translateY(14px);filter:blur(8px);
  transition:transform .5s var(--ease) .06s,
             filter .5s var(--ease) .06s;
}
.pfl-card:hover .pfl-bio-text,
.pfl-card:focus-visible .pfl-bio-text,
.pfl-card.is-open .pfl-bio-text{transform:none;filter:blur(0)}

/* Filete entre a foto e a assinatura. Era rgba(0,0,0,.2) cravado no
   original; --rule é exatamente esse degrau, e vem do escopo de tom. */
.pfl-rule{display:block;height:1px;background:var(--rule)}

.pfl-foot{display:flex;flex-direction:column;gap:8px}

/* O nome é fonte de TEXTO em corpo grande, não display: quem grita nesta
   dobra é o título e a esteira. Peso 400 é o teto — a nota do tema explica
   que pedir mais de uma face que só tem Light faz o navegador engordar a
   letra artificialmente. O contraste com o cargo vem do corpo, não do peso,
   e é assim no original também. */
.pfl-name{
  font-family:var(--sans);font-weight:400;
  font-size:clamp(22px,2.2vw,32px);line-height:1.2;letter-spacing:-.02em;
  color:var(--fg);
}
.pfl-role{font-size:16px;line-height:1.5;color:var(--fg-2)}

/* ---------------------------------------------------------------- esteira */
/* Tinta a 10%: é textura de fundo, não título. Acima disso ela disputa com
   o nome das pessoas e a dobra passa a ter dois assuntos. */
.pfl-strip{
  overflow:hidden;
  margin-block-start:clamp(56px,8vh,100px);
  opacity:.1;
  pointer-events:none;
  user-select:none;-webkit-user-select:none;
}

/* Dois conjuntos idênticos, o trilho anda -50% — a largura exata de um
   deles. É o que fecha o laço sem salto (mesma receita do
   prova/marquee-logos). */
.pfl-strip-rail{
  display:flex;width:max-content;
  animation:pflStrip var(--pfl-strip-dur) linear infinite;
}
.pfl-strip-set{
  display:flex;gap:var(--pfl-strip-gap);
  /* Mantém o vão na emenda entre as duas cópias. */
  padding-inline-end:var(--pfl-strip-gap);
}

.pfl-strip-word{
  font-family:var(--display);font-weight:700;
  font-size:var(--pfl-strip-size);
  line-height:1;letter-spacing:-.02em;
  text-transform:uppercase;
  color:var(--fg);
  white-space:nowrap;
}

@keyframes pflStrip{
  from{transform:translateX(0)}
  to{transform:translateX(-50%)}
}

/* ---------------------------------------------------------------- movimento reduzido */
/* Para a esteira de texto e apaga a transição do painel. O trilho de cards
   é tratado no script.js: sem movimento reduzido ele anda sozinho; com,
   fica parado e só o arrasto o move — gesto pedido pela pessoa não é
   movimento imposto. */
@media(prefers-reduced-motion:reduce){
  .pfl-strip-rail{animation:none}
  .pfl-bio{transition:none}
  .pfl-bio-text{transition:none;transform:none;filter:none}
}

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não existe hover. A bio continua alcançável — o script abre e
   fecha no toque — mas o cursor de arrasto não faz sentido. */
@media(hover:none){
  .pfl-track{cursor:default}
}

/* ---------------------------------------------------------------- mobile */
@media(max-width:900px){
  /* Uma coluna só. O padding-inline-start do grid continua valendo, então
     texto e primeiro card seguem alinhados na mesma margem e o trilho
     continua vazando pela direita — sem margem negativa e sem 100vw. */
  .pfl-grid{grid-template-columns:1fr;gap:clamp(32px,5vh,48px)}
}

@media(max-width:560px){
  .pfl{--pfl-title:clamp(38px,12vw,58px);--pfl-space:18px}
  .pfl-body{gap:16px}
  .pfl-card{gap:18px}
}

script.js · 370 linhas · 14,2 KB

/* ==========================================================================
   CARROSSEL DE PERFIS — laço infinito, arrasto e inércia.

   A esteira de texto do rodapé é CSS e não passa por aqui. Este script cuida
   só do trilho de cards, e faz cinco coisas:

     1. DUPLICA a fila até ela dar mais de uma volta na tela, marcando as
        cópias com aria-hidden — o laço precisa de fila sobrando, o leitor de
        tela não precisa ouvir a mesma pessoa três vezes;
     2. ANDA sozinho, a --pfl-speed pixels por segundo, com a conta feita por
        TEMPO e não por quadro (60Hz e 120Hz andam igual);
     3. ARRASTA com mouse ou dedo, com inércia ao soltar;
     4. PARA quando o ponteiro entra ou o foco chega — é quando a bio abre, e
        um card que desliza enquanto se lê é um card que não se lê;
     5. ABRE E FECHA a bio no toque, para quem não tem hover.

   Um rAF só, e ele mesmo se desliga quando não há nada para fazer: fora da
   tela, sob o ponteiro, ou com movimento reduzido e sem arrasto em curso.
   Nenhum listener de scroll — quem observa a entrada é o IntersectionObserver.

   MOVIMENTO REDUZIDO: o trilho não anda sozinho e não arremessa ao soltar.
   O arrasto continua funcionando, porque gesto pedido pela pessoa não é
   movimento imposto a ela.
   ========================================================================== */

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

  function iniciar(raiz) {
    var trilho = raiz.querySelector('[data-pfl-track]');
    if (!trilho) return;

    var originais = [].slice.call(trilho.children);
    if (!originais.length) return;

    var reduz = reduzido();

    /* Velocidade mora no CSS (--pfl-speed), em px/s, junto do resto dos
       ajustes do componente. Com movimento reduzido ela é zero. */
    var base = reduz ? 0
             : (parseFloat(getComputedStyle(raiz).getPropertyValue('--pfl-speed')) || 26);

    var pos = 0;          /* deslocamento do trilho, em px */
    var largura = 0;      /* largura de UM conjunto, vão incluído */
    var passoCard = 0;    /* distância de um card ao próximo */
    var vel = 0;          /* inércia do arrasto, em px por quadro */
    var salto = 0;        /* px que faltam do pulo pedido pelo teclado */
    var parado = false;   /* ponteiro em cima ou foco dentro */
    var dentro = false;   /* dobra visível */
    var laco = 0;
    var antes = 0;
    var copias = 1;

    var arrastando = false, ponteiro = -1, xAnterior = 0, dxUltimo = 0, andou = 0;

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

    /* A largura de um conjunto é a distância entre o primeiro original e o
       primeiro clone. Medir assim inclui o vão automaticamente — somar
       larguras na mão erraria por um gap. offsetLeft não enxerga o transform
       do pai, que é exatamente o que se quer aqui. */
    function medir() {
      if (copias < 2) return 0;
      return trilho.children[originais.length].offsetLeft - originais[0].offsetLeft;
    }

    function medirPasso() {
      if (trilho.children.length < 2) return 0;
      return trilho.children[1].offsetLeft - trilho.children[0].offsetLeft;
    }

    /* Duplica a fila.

       A conta NÃO é "encher duas telas". O que o laço precisa é: além do
       conjunto que está em cena, sobrar fila suficiente para cobrir a janela
       inteira do trilho — senão, no ponto em que o deslocamento dá a volta,
       aparece vazio entre o fim da fila e o começo dela. Daí
       (copias - 1) * conjunto >= janela.

       Medir a largura total em vez disso quebraria justamente no caso de
       time grande: dez perfis já passam de duas telas com UM conjunto só, o
       clone nunca seria criado, não haveria o que medir e o trilho correria
       até o fim da fila e travaria lá.

       O primeiro clone é obrigatório por um segundo motivo: é a distância
       entre ele e o original que dá a medida do conjunto (ver medir()).
       O teto de 12 conjuntos é rede contra um erro de medida virar laço
       infinito de clonagem. */
    function garantir() {
      var janela = raiz.getBoundingClientRect().width + 240;
      var conjunto;
      while (copias < 12) {
        if (copias >= 2) {
          conjunto = medir();
          if (conjunto > 0 && (copias - 1) * conjunto >= janela) break;
        }
        for (var i = 0; i < originais.length; i++) {
          var copia = originais[i].cloneNode(true);
          copia.setAttribute('aria-hidden', 'true');
          /* Sem data-rv: o clone não é observado pelo core (ele nasceu
             depois da varredura) e ficaria invisível para sempre. */
          copia.removeAttribute('data-rv');
          copia.removeAttribute('data-d');
          /* aria-hidden com filho focável é erro de acessibilidade: o foco
             entraria num pedaço da página que o leitor de tela não anuncia. */
          var focaveis = copia.querySelectorAll('[tabindex]');
          for (var f = 0; f < focaveis.length; f++) focaveis[f].setAttribute('tabindex', '-1');
          trilho.appendChild(copia);
        }
        copias++;
      }
    }

    function remedir() {
      garantir();
      largura = medir();
      passoCard = medirPasso();
    }

    /* ------------------------------------------------------------ rolagem do recorte */

    /* O .pfl-rail recorta com overflow:hidden, e isso faz dele um contêiner
       de ROLAGEM aos olhos do navegador — mesmo sem barra à vista. Duas
       coisas mexem nele por fora do nosso controle:

         - chegar num card por Tab: o navegador rola o recorte para trazer à
           vista o que recebeu foco, e faz isso DEPOIS do focusin (medido,
           não suposto — tratar dentro do focusin é cedo demais);
         - gesto horizontal de trackpad ou shift+roda sobre o trilho.

       Nos dois casos a fila passa a ter DUAS posições somadas — a nossa, no
       transform, e a do navegador, no scrollLeft — e a segunda nunca volta a
       zero sozinha: os cards ficam deslocados para sempre.

       O conserto é converter uma na outra. O que o navegador rolou vira
       deslocamento nosso e o recorte volta a zero; no mesmo quadro o
       resultado visual é idêntico, e sobra uma fonte só de verdade.

       De brinde, o gesto horizontal do trackpad passa a mover o carrossel,
       que é o que quem faz o gesto está pedindo. */
    var caixa = trilho.parentNode;

    function absorverRolagem() {
      if (!caixa) return;
      if (caixa.scrollTop) caixa.scrollTop = 0;
      var dx = caixa.scrollLeft;
      if (!dx) return;
      caixa.scrollLeft = 0;
      pos += dx;
    }

    /* Dois gatilhos de propósito, e não por desconfiança do evento: o
       listener é quem ACORDA o laço quando ele está dormindo (parado sob o
       ponteiro, ou movimento reduzido); a chamada dentro do passo() é quem
       garante que nenhum quadro seja desenhado com as duas posições somadas,
       venha o evento antes ou depois. */
    if (caixa) {
      caixa.addEventListener('scroll', function () {
        absorverRolagem();
        ligar();          /* um quadro basta: passo() escreve e se desliga */
      }, { passive: true });
    }

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

    /* O rAF só continua enquanto houver o que mover. Sem isto, uma dobra
       parada sob o ponteiro — ou uma página inteira em movimento reduzido —
       gastaria um quadro por vez para escrever o mesmo transform. */
    function ocupado() {
      return arrastando || vel !== 0 || salto !== 0 || (!parado && base > 0) ||
             (caixa && caixa.scrollLeft !== 0);
    }

    function passo(agora) {
      absorverRolagem();

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

      if (!arrastando) {
        if (salto) {
          var fatia = salto * .16;
          if (Math.abs(salto) < .6) fatia = salto;
          pos += fatia;
          salto -= fatia;
        } else if (!parado) {
          pos += base * dt;
        }
        if (vel) {
          pos += vel;
          vel *= .93;
          if (Math.abs(vel) < .03) vel = 0;
        }
      }

      /* O laço: o deslocamento vive sempre dentro de um conjunto. while e
         não if — um arremesso forte pode passar de uma volta num quadro só. */
      if (largura > 0) {
        while (pos >= largura) pos -= largura;
        while (pos < 0) pos += largura;
      }

      trilho.style.transform = 'translate3d(' + (-pos) + 'px,0,0)';

      laco = (dentro && ocupado()) ? requestAnimationFrame(passo) : 0;
    }

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

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

    /* ------------------------------------------------------------ arrasto */

    /* O navegador tem um arrasto PRÓPRIO para imagem: puxar uma foto descola
       dela um fantasma translúcido e o gesto vira um drag-and-drop de
       arquivo — o trilho fica parado e o cursor mostra o ícone de "soltar
       aqui". setPointerCapture não impede: são dois mecanismos separados,
       e o nativo nasce do dragstart, que só se cancela cancelando.

       É o irmão do problema que o user-select:none resolve no CSS. Os dois
       existem pelo mesmo motivo: dentro de um trilho arrastável, todo gesto
       de puxar pertence ao trilho. */
    trilho.addEventListener('dragstart', function (e) { e.preventDefault(); });

    trilho.addEventListener('pointerdown', function (e) {
      if (typeof e.button === 'number' && e.button !== 0) return;
      arrastando = true;
      andou = 0;
      vel = 0;
      salto = 0;
      ponteiro = e.pointerId;
      xAnterior = e.clientX;
      dxUltimo = 0;
      trilho.classList.add('is-dragging');
      /* Captura para o arrasto sobreviver ao ponteiro sair do trilho. */
      try { trilho.setPointerCapture(ponteiro); } catch (err) {}
      ligar();
    });

    trilho.addEventListener('pointermove', function (e) {
      if (!arrastando || e.pointerId !== ponteiro) return;
      var dx = e.clientX - xAnterior;
      xAnterior = e.clientX;
      dxUltimo = dx;
      andou += Math.abs(dx);
      pos -= dx;
    });

    function soltar(e) {
      if (!arrastando) return;
      if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
      arrastando = false;
      trilho.classList.remove('is-dragging');
      try { trilho.releasePointerCapture(ponteiro); } catch (err) {}
      /* O último deslocamento vira arremesso e decai. Com movimento
         reduzido o trilho para onde a mão soltou. */
      vel = reduz ? 0 : -dxUltimo * .9;
      ligar();
    }

    trilho.addEventListener('pointerup', soltar);
    trilho.addEventListener('pointercancel', soltar);

    /* ------------------------------------------------------------ bio no toque */

    function cardDe(no) {
      while (no && no !== trilho) {
        if (no.classList && no.classList.contains('pfl-card')) return no;
        no = no.parentNode;
      }
      return null;
    }

    function abrir(card) {
      var abertos = trilho.querySelectorAll('.pfl-card.is-open');
      for (var i = 0; i < abertos.length; i++) {
        if (abertos[i] !== card) abertos[i].classList.remove('is-open');
      }
      card.classList.toggle('is-open');
    }

    /* 6px de tolerância: um clique sempre treme um pouco, e um arrasto de
       verdade passa longe disso. Sem o limiar, arrastar a fila abriria a bio
       do card onde a mão soltou. */
    trilho.addEventListener('click', function (e) {
      if (andou > 6) return;
      var card = cardDe(e.target);
      if (card) abrir(card);
    });

    /* ------------------------------------------------------------ teclado */

    trilho.addEventListener('keydown', function (e) {
      var card = cardDe(e.target);

      if (card && (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar')) {
        e.preventDefault();
        abrir(card);
        return;
      }

      var direcao = 0;
      if (e.key === 'ArrowRight') direcao = 1;
      else if (e.key === 'ArrowLeft') direcao = -1;
      else return;

      e.preventDefault();
      vel = 0;
      salto += direcao * (passoCard || 300);
      ligar();
    });

    /* ------------------------------------------------------------ pausa */
    /* mouseenter/mouseleave e não pointerenter: no toque não existe hover, e
       pausar ali deixaria o trilho parado desde o primeiro toque. */

    trilho.addEventListener('mouseenter', function () { parado = true; });
    trilho.addEventListener('mouseleave', function () { parado = false; ligar(); });
    trilho.addEventListener('focusout', function () { parado = false; ligar(); });

    /* Chegar por Tab também acorda o laço: o navegador vai rolar o recorte
       logo em seguida, e é absorverRolagem() quem devolve isso para o
       deslocamento. Ver o bloco "rolagem do recorte". */
    trilho.addEventListener('focusin', function () { parado = true; ligar(); });

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

    var pedido = 0;
    addEventListener('resize', function () {
      if (pedido) return;
      /* Uma medida por quadro. garantir() e medir() leem layout, e chamar os
         dois 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(); else desligar();
      }
    }, { threshold: 0 });

    remedir();
    io.observe(raiz);
  }

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

Três peças numa dobra só. À esquerda, uma coluna estreita com título display gigante e dois parágrafos — o argumento. À direita, um trilho de cards de pessoa que anda sozinho devagar, sangra pela borda direita da tela e obedece ao arrasto do mouse ou do dedo, com inércia ao soltar. Cada card é foto quase quadrada, um filete e duas linhas: nome e cargo. Passar o ponteiro sobre a foto faz um painel na cor de marca subir e cobri-la com a bio da pessoa. No chão da seção, uma esteira de texto gigante em tinta a 10% atravessa a dobra e encosta na seção seguinte.

Resolve a página de "quem faz" quando o time não cabe numa grade e cada pessoa tem história para contar. A grade obriga a escolher entre mostrar poucos com bio ou muitos sem. O trilho resolve as duas: mostra três de cada vez, tem quantos precisar, e a bio fica guardada até alguém querer.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Título Uma linha, duas palavras. Caixa alta é do CSS — escreva normal.
Parágrafo 1 A imagem que resume a postura do time. Uma frase, no máximo duas linhas.
Parágrafo 2 O que eles entregam na prática. Quatro a seis linhas.
Nome Nome e sobrenome, sem título acadêmico.
Cargo Uma linha, até 60 caracteres. Duas linhas desalinham a fileira inteira.
Bio 380 a 620 caracteres. Abaixo disso o painel fica vazio; acima, o texto passa da altura da foto e é cortado.

O que mantém a fileira coesa é o paralelismo: se uma bio começa por formação, todas começam. Se uma menciona a empresa anterior, todas mencionam. Bio de tamanhos muito diferentes fica evidente porque os painéis têm exatamente a mesma altura.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/midia/carrossel-perfis/estilo.css">
<script src="componentes/midia/carrossel-perfis/script.js"></script>

Ganchos

Gancho Papel
[data-pfl] Raiz. O script parte daqui, nunca do documento.
[data-pfl-track] O <ul> que o script move, clona e escuta.
--pfl-inset Recuo que alinha a coluna de texto à margem da página. Refaz a conta do .wrap.
--pfl-col / --pfl-gap Largura da coluna de texto e vão até o trilho.
--pfl-card / --pfl-space Largura do card e vão entre um e o próximo.
--pfl-title Corpo do título. clamp(46px,6vw,104px) no padrão. Alimenta o --display-xl da primitiva do core.
--pfl-speed Velocidade do trilho, em pixels por segundo. 26 no padrão. 0 deixa parado.
--pfl-strip-size / --pfl-strip-gap / --pfl-strip-dur Corpo, vão e volta completa da esteira do rodapé.
--pfl-floor Respiro abaixo da esteira. 0px no padrão — a esteira é o chão da dobra.
.is-open No card: bio aberta. Quem põe e tira é o script, no clique.
.is-dragging No trilho, durante o arrasto. Só troca o cursor.

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
referência: Rony Meisler https://www.ronymeisler.com.br/investimentos — dobra "Os sócios" 2026-08-31 referência visual, não origem de código

Origem. Escrito do zero a partir da observação da dobra "Os sócios" da página de investimentos de ronymeisler.com.br — site publicado em Framer, CSS gerado por máquina, classes hasheadas, sem licença declarada. Nada foi copiado: o que veio de lá foi a medida, lida no navegador (colunas de 468 + 80 + trilho sangrado; card de 485 × 600 com foto de 485 × 449; painel da bio em rgb(137,147,136); esteira de 150px a 10% de opacidade), e a ideia de juntar as três peças numa dobra. Status externo: nunca rodou em produção nossa. O primeiro site que usar promove — e a ficha registra qual foi. A referência está registrada em projetos/referencias-externas.md.

Normalizações aplicadas na tradução:

Divergências conhecidas vs. a referência: