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

Carrossel de foco central

🟢 estavel complexidade alta prefixo focus- carrossel scroll-snap foco-central arrasto drag scroll-horizontal solucoes servicos imagem teclado pontos contador

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

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

markup.html · 180 linhas · 9,5 KB

<!-- ==========================================================================
     CARROSSEL DE FOCO CENTRAL — a fila em que o card do meio manda
     Prefixo: focus-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. Quem rola é o navegador: scroll-snap nativo, encaixe no CENTRO.

     O MOTOR É O NAVEGADOR. O trilho é um contêiner com overflow-x:auto e
     scroll-snap-type:x mandatory; cada card tem scroll-snap-align:center. Todo
     o gesto — dedo, trackpad, barra de espaço, roda horizontal — já funciona
     antes de o script existir. O script.js não move nada: ele mede a distância
     de cada card ao centro do trilho e escreve --focus-k em cada item. A escala
     e o escurecimento são CSS lendo essa variável. Se o JS falhar, sobra um
     carrossel de encaixe legível, com todos os cards em tamanho cheio — por
     isso --focus-k tem 0 como padrão no CSS, e não 1.

     ALINHAMENTO AO CENTRO, NÃO À COLUNA. O midia/galeria-scroll-horizontal
     alinha o primeiro card à borda da coluna, com --hgal-inset. Aqui não dá:
     encaixe no centro significa que o card ativo tem de poder chegar ao meio
     da tela, e o primeiro e o último só chegam lá se houver meia tela de
     respiro antes e depois deles. É o que faz o padding-inline do trilho:
     calc(50% - var(--focus-card) / 2). O trilho continua IRMÃO do .wrap e
     filho direto da seção — o 50% é metade da largura da seção.

     A SEÇÃO É UM GRID de uma coluna, e não um bloco comum. Motivo único: os
     dois botões precisam ficar à direita do título no desktop e embaixo de
     tudo no celular. Como cabeçalho e rodapé são caixas diferentes, order de
     flex não resolve — os dois .wrap (cabeçalho e navegação) ocupam a MESMA
     célula do grid, e no celular a navegação ganha uma linha própria no fim.
     A navegação tem pointer-events:none na caixa e auto nos botões, senão a
     metade direita do título ficaria sob uma caixa invisível.

     A LEGENDA DO RODAPÉ É CONTEÚDO, não atributo. As seis frases estão no HTML
     como seis <p> de verdade; cinco nascem com [hidden] e o script troca qual
     deles aparece. Guardar a frase num data-* deixaria o texto fora do
     documento — e sem JS não sobraria nenhuma. O contêiner é aria-live:
     trocar o [hidden] é uma mudança de conteúdo, e é ela que o leitor de tela
     anuncia. O script só escreve quando o card ativo MUDA, nunca por quadro.

     O TRILHO É FOCÁVEL (tabindex="0"). Região que rola precisa ser alcançável
     pelo teclado, e é ali que as setas ←/→, Home e End andam de card em card.
     Cada card também é um <a> de verdade: Tab percorre os seis, Enter abre, e
     o focusin centraliza o card que recebeu foco. role="list" está ali porque
     o reset do core tira list-style, e sem list-style o Safari deixa de
     anunciar "lista de 6 itens" — que é justamente o que o scroll horizontal
     esconde de quem não vê a fila continuar fora da tela.
     ========================================================================== -->

<section class="sec-dark sec-pad focus" id="solucoes" data-focus>

  <div class="wrap focus-head">
    <span class="eyebrow" data-rv>Soluções</span>
    <h2 class="sec-title ttl">Uma frente para cada <em>momento</em></h2>
  </div>

  <div class="wrap focus-nav" data-rv data-d="1">
    <button class="btn-disc focus-btn" type="button" data-focus-prev aria-label="Ver a solução anterior">
      <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
        <path d="M10 2L4 8L10 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
    </button>
    <button class="btn-disc focus-btn" type="button" data-focus-next aria-label="Ver a próxima solução">
      <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
        <path d="M6 2L12 8L6 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
    </button>
  </div>

  <ul class="focus-track" data-focus-track role="list" tabindex="0"
      aria-label="Soluções — arraste para o lado ou use as setas do teclado">

    <li class="focus-item" data-rv>
      <a class="focus-card" href="#consultoria">
        <span class="focus-media">
          <img class="focus-img" src="../../../showroom/demo/ph-1.webp"
               alt="Casal em uma varanda com vista para um vilarejo" decoding="async">
          <span class="focus-cap">
            <span class="pill focus-tag">Consultoria de investimentos</span>
            <span class="focus-title">Carteira desenhada para o seu horizonte</span>
          </span>
        </span>
      </a>
    </li>

    <li class="focus-item" data-rv data-d="1">
      <a class="focus-card" href="#sucessao">
        <span class="focus-media">
          <img class="focus-img" src="../../../showroom/demo/ph-2.webp"
               alt="Família caminhando por uma alameda arborizada" loading="lazy" decoding="async">
          <span class="focus-cap">
            <span class="pill focus-tag">Planejamento sucessório</span>
            <span class="focus-title">A transferência organizada antes da urgência</span>
          </span>
        </span>
      </a>
    </li>

    <li class="focus-item" data-rv data-d="2">
      <a class="focus-card" href="#internacional">
        <span class="focus-media">
          <img class="focus-img" src="../../../showroom/demo/ph-6.webp"
               alt="Globo antigo sobre uma mesa de biblioteca" loading="lazy" decoding="async">
          <span class="focus-cap">
            <span class="pill focus-tag">Estruturas internacionais</span>
            <span class="focus-title">Patrimônio em mais de uma jurisdição</span>
          </span>
        </span>
      </a>
    </li>

    <li class="focus-item" data-rv>
      <a class="focus-card" href="#previdencia">
        <span class="focus-media">
          <img class="focus-img" src="../../../showroom/demo/ph-5.webp"
               alt="Homem mais velho e criança lendo juntos" loading="lazy" decoding="async">
          <span class="focus-cap">
            <span class="pill focus-tag">Previdência e proteção</span>
            <span class="focus-title">Renda definida e seguro que fecha a conta</span>
          </span>
        </span>
      </a>
    </li>

    <li class="focus-item" data-rv>
      <a class="focus-card" href="#credito">
        <span class="focus-media">
          <img class="focus-img" src="../../../showroom/demo/ph-8.webp"
               alt="Mesa com xícara de café, caderno e óculos" loading="lazy" decoding="async">
          <span class="focus-cap">
            <span class="pill focus-tag">Crédito e câmbio</span>
            <span class="focus-title">Liquidez sem desmontar a carteira</span>
          </span>
        </span>
      </a>
    </li>

    <li class="focus-item" data-rv>
      <a class="focus-card" href="#empresas">
        <span class="focus-media">
          <img class="focus-img" src="../../../showroom/demo/ph-7.webp"
               alt="Reunião em uma sala de vidro" loading="lazy" decoding="async">
          <span class="focus-cap">
            <span class="pill focus-tag">Empresas e sócios</span>
            <span class="focus-title">Caixa, sucessão e venda de participação</span>
          </span>
        </span>
      </a>
    </li>

  </ul>

  <div class="wrap focus-foot" data-rv>

    <!-- Seis frases, uma visível. O aria-live fica AQUI, no contêiner, e não
         em cada frase: quem muda é qual filho está visível. -->
    <div class="focus-notes" data-focus-notes aria-live="polite">
      <p class="focus-note">Diagnóstico, política de investimentos e rebalanceamento acompanhado pelo comitê. A carteira muda quando a sua vida muda.</p>
      <p class="focus-note" hidden>Holding familiar, doação em vida e testamento desenhados juntos, para que a transferência não dependa de um inventário.</p>
      <p class="focus-note" hidden>Conta, custódia e veículo no exterior, com relatório consolidado em uma moeda só e câmbio tratado como parte do plano.</p>
      <p class="focus-note" hidden>Previdência com portabilidade avaliada e seguro dimensionado pelo que a família precisaria receber, não pelo que sobra.</p>
      <p class="focus-note" hidden>Crédito com garantia da própria carteira e operações de câmbio para despesa no exterior, sem vender posição no pior mês.</p>
      <p class="focus-note" hidden>Caixa da empresa separado do patrimônio da família, acordo de sócios revisado e a venda de participação preparada antes.</p>
    </div>

    <div class="focus-bar">
      <div class="focus-dots" data-focus-dots>
        <button class="focus-dot" type="button" aria-label="Ir para o card 1" aria-current="true"></button>
        <button class="focus-dot" type="button" aria-label="Ir para o card 2"></button>
        <button class="focus-dot" type="button" aria-label="Ir para o card 3"></button>
        <button class="focus-dot" type="button" aria-label="Ir para o card 4"></button>
        <button class="focus-dot" type="button" aria-label="Ir para o card 5"></button>
        <button class="focus-dot" type="button" aria-label="Ir para o card 6"></button>
      </div>
      <!-- Contador aria-hidden: os pontos já dizem a posição, e cada um deles
           tem rótulo próprio. Anunciar "02 / 06" de novo seria eco. -->
      <p class="focus-count" aria-hidden="true"><span class="focus-count-now" data-focus-now>01</span> / 06</p>
    </div>

  </div>

</section>

estilo.css · 325 linhas · 14,2 KB

/* ==========================================================================
   CARROSSEL DE FOCO CENTRAL — prefixo focus-
   Fila de cards retrato com encaixe no CENTRO. O card centrado está em
   tamanho e brilho cheios; os vizinhos encolhem e escurecem em proporção à
   distância que os separa do meio do trilho.

   Tokens usados:
     --display            título do card e contador (o texto de apoio herda
                          --sans do core, sem declarar)
     --ease               curva única do projeto, em toda transição
     --accent             ponto ativo e realce dos botões
     --dark-rgb           véu em gradiente da legenda sobre a foto
     --light              texto sobre a foto — o card é ilha escura
     --radius-media       canto da foto
     --radius-pill        disco dos botões, ponto e pílula de categoria
     --rule --rule-2      borda do botão e ponto em repouso
     --bg                 fundo enquanto a foto não pintou
     --fg --fg-2 --fg-muted   legenda do rodapé, contador e botões
     --pad --max          NÃO são lidos aqui: o cabeçalho e o rodapé são
                          .wrap, e a coluna deles vem de lá

   A CONTA QUE SUBSTITUI O --inset. Os componentes irmãos que sangram alinham
   o primeiro item à borda da COLUNA (--hgal-inset, --depo-inset). Aqui o
   encaixe é no CENTRO: o card ativo tem de conseguir chegar ao meio da tela,
   e o primeiro e o último só chegam se houver meia tela de sobra antes e
   depois deles. Daí padding-inline:calc(50% - card/2) no trilho — metade da
   seção menos metade do card. O 50% é da SEÇÃO: o trilho tem de continuar
   filho direto dela.

   O QUE O SCRIPT ESCREVE. Só --focus-k, um número de 0 a 1 por card: 0 no
   centro, 1 a um passo de distância. A escala e o brilho são estas duas
   linhas de CSS. O padrão do var() é 0, não 1 — sem script, a fila fica
   inteira em tamanho cheio, que é o estado degradado legível.
   ========================================================================== */

.focus{
  /* Largura do card. O clamp faz o desktop mostrar o card do meio inteiro e
     boa parte dos dois vizinhos; no celular sobra a fatia que promete o
     próximo. Altura vem do aspect-ratio 4/5 da mídia: 520px de largura são
     650px de altura, e é por isso que o teto não sobe mais. */
  --focus-card:clamp(260px,40vw,520px);
  --focus-gap:clamp(14px,2vw,26px);

  /* Os dois parafusos da hierarquia, em fração de 0 a 1.
     escala: o vizinho fica scale(1 - k * --focus-scale)  -> .14 = 86%
     brilho: o vizinho fica brightness(1 - k * --focus-dim) -> .45 = 55% */
  --focus-scale:.14;
  --focus-dim:.45;

  /* --dur do core é .9s: certo para entrada de seção, lento demais para a
     foto responder ao ponteiro. Duração de interação é local. */
  --focus-dur:.55s;

  /* A seção é um GRID de uma coluna por um motivo só: no desktop os dois
     botões ficam na mesma célula do cabeçalho, alinhados pela base e pela
     direita; no celular eles ganham uma linha própria depois do rodapé.
     Cabeçalho e rodapé são caixas diferentes, então order de flex não
     resolveria — grid resolve, e sem posicionamento absoluto. */
  display:grid;
  grid-template-columns:100%;
  row-gap:clamp(26px,4vh,44px);
}

/* ---------------------------------------------------------------- cabeçalho */
.focus-head{grid-row:1;grid-column:1;min-width:0}

/* MESMA CÉLULA do cabeçalho. Os dois são .wrap, então os dois pousam
   exatamente sobre a coluna — o título encosta na esquerda, os botões na
   direita, e nada precisa saber a largura do outro.

   pointer-events:none na caixa é obrigatório: ela cobre a largura inteira da
   coluna, e sem isso a metade direita do título ficaria sob um retângulo
   invisível que engole clique e seleção. Os botões devolvem auto. */
.focus-nav{
  grid-row:1;grid-column:1;
  align-self:end;
  display:flex;justify-content:flex-end;gap:12px;
  pointer-events:none;
}

/* Disco de ícone, o mesmo desenho do midia/galeria-scroll-horizontal. Não é
   .btn: .btn é pílula de TEXTO com padding fixo, e espremer 42px nela seria
   brigar com a primitiva. Terceira aparição do disco na biblioteca — se
/* O disco de ícone é a primitiva .btn-disc do core (core/base.css): forma,
   cor, hover e estado desabilitado vêm de lá, e o markup traz as duas
   classes. Esta pasta escrevia as mesmas seis linhas — eram cinco cópias
   idênticas na biblioteca, e o §6 manda promover na terceira. */
   Sobra uma linha só, e ela é desta dobra: a faixa de navegação inteira é
   pointer-events:none para não cobrir o trilho que passa por baixo, então o
   botão precisa devolver o seu. */
.focus-btn{pointer-events:auto}

/* ---------------------------------------------------------------- trilho */
.focus-track{
  grid-row:2;grid-column:1;
  display:flex;align-items:flex-start;
  gap:var(--focus-gap);
  margin:0;list-style:none;

  /* A conta do topo do arquivo: meia seção menos meio card, dos dois lados.
     max(0px,…) é rede para a tela mais estreita que o card mínimo, onde a
     conta iria a negativo. */
  padding-inline:max(0px,calc(50% - var(--focus-card) / 2));
  /* Respiro para o anel de foco, que o core desenha 3px para fora e o
     overflow cortaria. */
  padding-block:10px 14px;

  overflow-x:auto;overflow-y:hidden;
  scroll-snap-type:x mandatory;
  /* Com scroll-snap-align:center o snapport é a caixa de padding inteira, e
     o centro dela já é o centro do trilho: scroll-padding não é preciso.
     Medido — o alvo do scrollIntoView e a parada do snap batem em 0px. */
  overscroll-behavior-x:contain;
  -webkit-overflow-scrolling:touch;

  /* Barra escondida nos três motores. É o que obriga os botões e os pontos a
     existirem: sem barra, some o último indício de que há mais fila. */
  scrollbar-width:none;
  -ms-overflow-style:none;
}
.focus-track::-webkit-scrollbar{width:0;height:0;display:none}

/* Enquanto a mão puxa, o encaixe SAI do caminho. Snap obrigatório durante o
   arrasto briga com o dedo: o navegador tenta voltar ao ponto de parada a
   cada quadro e o trilho fica emborrachado. Quem devolve o encaixe é o
   script, e só depois que o deslize de volta assentou. */
.focus-track.is-free{scroll-snap-type:none}
.focus-track.is-dragging,
.focus-track.is-dragging .focus-card{cursor:grabbing}
.focus-track.is-dragging{user-select:none;-webkit-user-select:none}

.focus-item{
  flex:0 0 var(--focus-card);min-width:0;
  scroll-snap-align:center;
}

/* ---------------------------------------------------------------- card */
/* As duas linhas que o --focus-k comanda. Não há transition aqui de
   propósito: o script reescreve --focus-k a cada quadro do scroll, e uma
   transição reiniciada a cada quadro faz o card perseguir o valor com
   atraso — a fila inteira lê emborrachada. A suavidade vem do scroll, que
   já é contínuo. Ver o gotcha da ficha, com os números. */
.focus-card{
  display:block;position:relative;
  transform:scale(calc(1 - var(--focus-k,0) * var(--focus-scale)));
  filter:brightness(calc(1 - var(--focus-k,0) * var(--focus-dim)));
}
/* Sob movimento reduzido a escala ENCOLHE, não morre — e o brilho fica
   inteiro, porque escurecer não é deslocamento. É a mesma decisão do
   midia/galeria-scroll-horizontal, onde o zoom do hover cai de 1.06 para
   1.02 em vez de sumir. Zerar aqui deixaria seis cards idênticos e a dobra
   pararia de comunicar qual está ativo, que é a única coisa que ela faz.
   O que o script desliga é outra coisa: o deslocamento programado passa a
   behavior:'auto' e a legenda troca sem transição. */
@media(prefers-reduced-motion:reduce){
  .focus{--focus-scale:.05}
}

.focus-media{
  display:block;position:relative;
  aspect-ratio:4/5;
  overflow:hidden;
  border-radius:var(--radius-media);
  background:var(--bg);
}

.focus-img{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
}
/* 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. Aqui o card sob o ponteiro já responde pela
   borda e pelo estado ativo do trilho; a foto fica parada. */


/* Legenda DENTRO da foto, ancorada embaixo, com véu em gradiente mascarado
   (contrato §6 — texto sobre imagem). O backdrop-filter continua enxergando
   a foto mesmo com o filter:brightness do card acima: o filtro do card cria
   uma raiz de backdrop, e a <img> está DENTRO dela. Medido. */
.focus-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  display:flex;flex-direction:column;align-items:flex-start;gap:12px;
  padding:66px 26px 24px;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.92) 0%,
    rgba(var(--dark-rgb),.52) 46%,
    transparent 100%);
  backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
}

/* Reaproveita a .pill do core; só o tom muda, porque a pílula está sobre foto
   e não sobre a seção. Seletor duplo de propósito: .pill e .focus-tag têm a
   mesma especificidade, e o contrato §6 proíbe depender de qual folha
   carregou primeiro. */
.focus-cap .focus-tag{
  color:var(--light);
  border-color:color-mix(in srgb,var(--light) 32%,transparent);
}

.focus-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(18px,1.7vw,25px);line-height:1.16;letter-spacing:-.022em;
  color:var(--light);
  max-width:15ch;
}

/* ---------------------------------------------------------------- rodapé */
.focus-foot{
  grid-row:3;grid-column:1;
  display:flex;align-items:flex-end;justify-content:space-between;
  gap:clamp(20px,4vw,56px);
}

/* Piso de três linhas, medido: a 52ch, uma frase de 120 caracteres ocupa 3
   linhas e uma de 95 ocupa 2. Sem o piso, a barra de pontos sobe e desce a
   cada troca de card — e ela está alinhada pela BASE justamente para poder
   ficar parada. Mexeu no comprimento das frases ou no max-width, refaça a
   conta: o piso é em em, então acompanha o corpo do texto sozinho. */
.focus-notes{
  max-width:52ch;min-width:0;
  min-height:calc(1.6em * 3);
  transition:opacity .15s var(--ease),filter .15s var(--ease),transform .15s var(--ease);
}
/* Meio caminho da troca: some com blur e um passo para baixo, o script troca
   qual frase está visível, e ela volta. 150ms para cada metade — os 300ms da
   troca inteira. Em repouso não há filter declarado, para não custar camada. */
.focus-notes.is-swap{opacity:0;filter:blur(10px);transform:translateY(8px)}
@media(prefers-reduced-motion:reduce){
  .focus-notes{transition:none}
  .focus-notes.is-swap{opacity:1;filter:none;transform:none}
}

/* Sem regra de display: assim o [hidden] do navegador continua valendo.
   Declarar display aqui venceria o [hidden] e as seis frases apareceriam
   empilhadas — é a armadilha que o .hgal-nav[hidden] documenta do outro
   lado, e a saída aqui é simplesmente não declarar. */
.focus-note{
  font-size:clamp(15px,1.15vw,17px);line-height:1.6;
  color:var(--fg-2);
}

.focus-bar{
  display:flex;align-items:center;gap:clamp(16px,2vw,26px);
  flex:0 0 auto;padding-bottom:2px;
}

.focus-dots{display:flex;align-items:center;gap:7px}

/* O alvo tem 22x26; o ponto visível tem 8. Um botão de 8px é impossível de
   acertar com o dedo, e o alvo maior não muda o desenho. */
.focus-dot{
  width:22px;height:26px;padding:0;
  border:0;background:none;
  display:grid;place-items:center;
}
.focus-dot::before{
  content:"";
  width:8px;height:8px;border-radius:var(--radius-pill);
  background:var(--rule-2);
  transition:background .3s var(--ease),width .3s var(--ease);
}
.focus-dot:hover::before{background:var(--fg-muted)}
/* O estado vem do atributo que o leitor de tela já lê, não de uma classe
   paralela: uma fonte de verdade só. */
.focus-dot[aria-current="true"]::before{background:var(--accent);width:22px}

.focus-count{
  font-family:var(--display);font-weight:700;
  font-size:11.5px;letter-spacing:.12em;
  color:var(--fg-muted);white-space:nowrap;
}
.focus-count-now{color:var(--fg)}

/* ---------------------------------------------------------------- mobile */
@media(max-width:760px){
  .focus{
    /* A fatia do vizinho que aparece é (tela - card) / 2 - vão, e ainda
       encolhe pela escala. Medido em 390px: com 76vw sobravam 21px do
       próximo — não é promessa, é um risco escuro na borda. Com 68vw e o vão
       menor sobram ~40px, que já se leem como um card cortado. Custa 30px de
       largura do card ativo, e vale. */
    --focus-card:min(68vw,310px);
    --focus-gap:12px;
    /* Cabe menos tela: com .14 o vizinho encolheria a ponto de a fatia à
       mostra sumir de novo. .08 mantém a hierarquia legível. */
    --focus-scale:.08;
    --focus-dim:.4;
    row-gap:22px;
  }
  /* Os botões saem da célula do cabeçalho e ganham linha própria, embaixo da
     legenda e da barra de pontos — onde o polegar alcança. */
  .focus-nav{
    grid-row:4;grid-column:1;
    align-self:auto;justify-content:flex-start;
    pointer-events:auto;
  }
  /* A pílula é uma FORMA: só se sustenta em uma linha. "Consultoria de
     investimentos" tem 28 caracteres e não cabe na largura do card de
     celular — a borda quebra em duas e a pílula vira um retângulo torto.
     Aqui o rótulo abre mão da pílula e vira linha de rótulo, que quebra sem
     ficar feia. Encurtar o conteúdo para caber seria o caminho errado. */
  .focus-cap .focus-tag{
    border-color:transparent;
    padding:0;
    font-size:10px;letter-spacing:.14em;
  }
  .focus-foot{flex-direction:column;align-items:flex-start;gap:20px}
  .focus-bar{padding-bottom:0}
  .focus-notes{min-height:calc(1.6em * 3)}
  .focus-cap{padding:52px 18px 18px;gap:9px}
}
/* O bloco de largura acima reescreve --focus-scale e venceria o de movimento
   reduzido lá de cima por ordem de fonte. Esta query é a única forma de
   manter a promessa nos dois eixos ao mesmo tempo — e por ser de largura,
   mora aqui embaixo com as outras. */
@media(max-width:760px) and (prefers-reduced-motion:reduce){
  .focus{--focus-scale:.04}
}
@media(max-width:420px){
  .focus-title{font-size:17px}
  .focus-notes{min-height:calc(1.6em * 4)}
}

script.js · 346 linhas · 14,8 KB

/* ==========================================================================
   CARROSSEL DE FOCO CENTRAL — medidor de distância ao centro.

   O script NÃO move a fila. Quem rola é o navegador: overflow-x:auto com
   scroll-snap-type:x mandatory e scroll-snap-align:center em cada card. Todo
   o gesto de toque, de trackpad e de barra de rolagem já funciona sem uma
   linha daqui. O que este arquivo faz são cinco coisas:

     1. MEDE, num rAF preso ao scroll DO TRILHO, a distância de cada card ao
        centro dele, e escreve --focus-k (0 no centro, 1 a um card de
        distância) em cada item. A escala e o escurecimento são CSS lendo
        essa variável — nenhum transform é escrito aqui;
     2. ELEGE o card ativo (o de menor distância) e atualiza legenda, pontos,
        contador e o disabled dos dois botões. A legenda só é reescrita
        quando o ativo MUDA: ela vive num aria-live, e escrever por quadro
        faria o leitor de tela falar sem parar;
     3. ANDA de card em card pelos botões, pelos pontos e pelas setas do
        teclado, sempre por scrollIntoView({inline:'center'}) — assim quem
        decide onde o card para continua sendo o encaixe do CSS;
     4. ARRASTA com o mouse. Roda vertical não move contêiner que rola no
        eixo x, e sem arrasto metade das pessoas não passa do segundo card.
        No toque não fazemos nada: o scroll nativo já é o arrasto;
     5. SUSPENDE o encaixe durante o arrasto (.is-free) e o devolve só depois
        que o deslize de volta assentou — devolver antes faz o navegador dar
        um salto seco até o ponto de parada mais próximo.

   Um rAF por instância, agendado pelo scroll do trilho, e ele dorme assim que
   não há mais nada a medir. Nenhum listener de scroll de PÁGINA: este é
   scroll de ELEMENTO, e o LIB.onScroll do core não o enxerga.

   MOVIMENTO REDUZIDO: os deslocamentos programados passam a behavior:'auto'
   e a troca da legenda perde a transição. A escala e o brilho continuam,
   com amplitude menor (o CSS baixa --focus-scale): eles não são deslocamento
   imposto, são a hierarquia que diz qual card está ativo — e sem eles a
   dobra deixaria de comunicar o que ela existe para comunicar.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-focus]');
  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 dois(n) { return (n < 10 ? '0' : '') + n; }

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

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

    var reduz  = reduzido();
    var notas  = raiz.querySelector('[data-focus-notes]');
    var frases = notas ? [].slice.call(notas.children) : [];
    var pontos = [].slice.call(raiz.querySelectorAll('[data-focus-dots] .focus-dot'));
    var conta  = raiz.querySelector('[data-focus-now]');
    var antes  = raiz.querySelector('[data-focus-prev]');
    var depois = raiz.querySelector('[data-focus-next]');

    var ativo = -1;        /* índice do card centrado */
    var ks = [];           /* último --focus-k escrito em cada card */
    var pedido = 0;        /* id do rAF pendente */
    var troca = 0;         /* id do timeout da troca de legenda */

    var livre = false;     /* encaixe suspenso (.is-free) */
    var parados = 0;       /* quadros seguidos sem o scroll mudar */
    var ultimo = -1;
    var correcoes = 0;     /* tentativas de endireitar antes de devolver o encaixe */

    var arrastando = false, capturado = false;
    var ponteiro = -1, x0 = 0, s0 = 0, andou = 0;

    /* ------------------------------------------------------------ legenda */

    function mostrar(i) {
      for (var n = 0; n < frases.length; n++) frases[n].hidden = (n !== i);
    }

    /* Some com blur, troca no meio do caminho, volta. O clearTimeout é o que
       impede um arrasto rápido de disparar seis anúncios no aria-live: só a
       última troca dentro da janela de 150ms chega a ser escrita. */
    function trocar(i) {
      if (!notas) return;
      if (reduz) { mostrar(i); return; }
      notas.classList.add('is-swap');
      if (troca) clearTimeout(troca);
      troca = setTimeout(function () {
        troca = 0;
        mostrar(i);
        notas.classList.remove('is-swap');
      }, 150);
    }

    function marcar(i) {
      var anterior = ativo;
      ativo = i;
      for (var d = 0; d < pontos.length; d++) {
        if (d === i) pontos[d].setAttribute('aria-current', 'true');
        else pontos[d].removeAttribute('aria-current');
      }
      if (conta) conta.textContent = dois(i + 1);
      /* Na primeira pintura não há troca: a legenda certa já está no HTML. */
      if (anterior > -1) trocar(i); else mostrar(i);
    }

    /* ------------------------------------------------------------ deslocar */

    function irPara(i) {
      if (i < 0) i = 0;
      if (i > itens.length - 1) i = itens.length - 1;
      var como = { behavior: reduz ? 'auto' : 'smooth',
                   inline: 'center',
                   block: 'nearest' };   /* block:nearest = a página não pula */
      try { itens[i].scrollIntoView(como); } catch (e) { itens[i].scrollIntoView(); }
      agendar();
    }

    /* ------------------------------------------------------------ medida */

    function pintar() {
      pedido = 0;

      /* LEITURAS agrupadas antes de qualquer escrita — ler depois de escrever
         força um layout síncrono por card. */
      var caixa = trilho.getBoundingClientRect();
      var meio = caixa.left + caixa.width / 2;
      var rolagem = trilho.scrollLeft;
      var rects = [], i;
      for (i = 0; i < itens.length; i++) rects.push(itens[i].getBoundingClientRect());

      /* O passo é a distância de um card ao próximo — largura mais vão. Vem
         medido do DOM, e não da soma de --focus-card com --focus-gap: assim
         o componente não precisa saber que unidade o tema usou. */
      var passo = rects.length > 1 ? Math.abs(rects[1].left - rects[0].left) : 0;
      if (!(passo > 1)) passo = rects[0].width || 1;

      var melhor = 0, menor = Infinity, dist = [];
      for (i = 0; i < rects.length; i++) {
        dist[i] = Math.abs(rects[i].left + rects[i].width / 2 - meio);
        if (dist[i] < menor) { menor = dist[i]; melhor = i; }
      }

      /* ESCRITAS. Só quando o valor mudou o bastante para valer um pixel:
         um k que variou .0005 não move nada e ainda assim custaria recálculo
         de estilo em todos os cards, a cada quadro. */
      for (i = 0; i < itens.length; i++) {
        var k = dist[i] / passo;
        if (k > 1) k = 1;
        if (ks[i] === undefined || Math.abs(ks[i] - k) > .002) {
          ks[i] = k;
          itens[i].style.setProperty('--focus-k', k.toFixed(3));
        }
      }

      if (melhor !== ativo) marcar(melhor);

      /* Pontas pelo ÍNDICE do ativo, não pelo scrollLeft: em encaixe central
         o scrollLeft máximo depende de o motor contar o padding do fim do
         trilho no scrollWidth, e essa é uma conta que já divergiu entre
         motores. O índice não depende de ninguém. */
      var pa = (ativo <= 0), pb = (ativo >= itens.length - 1);
      if (antes && antes.disabled !== pa) antes.disabled = pa;
      if (depois && depois.disabled !== pb) depois.disabled = pb;

      /* Assentou? Sete quadros sem o scroll mudar valem por um scrollend, que
         ainda não existe em todo motor. Enquanto não assentou, o laço se
         reagenda sozinho; assim que assenta, dorme.

         Devolver o encaixe FORA de um ponto de parada custa um salto seco de
         até meio passo — medido, 246px. Então, se a fila parou torta (o
         deslize de volta foi interrompido por um gesto novo), primeiro
         endireitamos e só depois devolvemos. Já parados no ponto certo, o
         custo é zero — também medido. Duas tentativas bastam; o teto é rede
         contra um alvo inalcançável virar laço. */
      if (livre && !arrastando) {
        if (Math.abs(rolagem - ultimo) < .5) parados++; else parados = 0;
        ultimo = rolagem;
        if (parados < 7) {
          agendar();
        } else if (menor > 2 && correcoes < 2) {
          correcoes++; parados = 0;
          irPara(ativo);
        } else {
          parados = 0; correcoes = 0; livre = false;
          trilho.classList.remove('is-free');
        }
      }
    }

    function agendar() {
      if (pedido) return;
      pedido = requestAnimationFrame(pintar);
    }

    trilho.addEventListener('scroll', agendar, { passive: true });
    addEventListener('resize', agendar, { passive: true });

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

    /* Suspende o encaixe. Sem isto o arrasto simplesmente não existe: com
       scroll-snap-type:x mandatory, escrever scrollLeft = 300 devolve 546 no
       mesmo quadro — o motor puxa de volta ao ponto de parada mais próximo,
       e a fila fica emborrachada sob o dedo. Medido. */
    function livrar() {
      if (livre) return;
      livre = true; parados = 0; correcoes = 0; ultimo = -1;
      trilho.classList.add('is-free');
    }

    /* O navegador tem arrasto PRÓPRIO para imagem e para link: puxar um card
       descolaria dele um fantasma translúcido e o gesto viraria drag-and-drop.
       setPointerCapture não impede — são dois mecanismos separados, e o
       nativo nasce do dragstart. */
    trilho.addEventListener('dragstart', function (e) { e.preventDefault(); });

    trilho.addEventListener('pointerdown', function (e) {
      andou = 0;
      /* No toque o scroll nativo JÁ é o arrasto, e capturar o ponteiro aqui
         mataria o embalo e a rolagem vertical da página junto. */
      if (e.pointerType === 'touch') return;
      if (typeof e.button === 'number' && e.button !== 0) return;
      arrastando = true;
      capturado = false;
      ponteiro = e.pointerId;
      x0 = e.clientX;
      s0 = trilho.scrollLeft;
      trilho.classList.add('is-dragging');
      livrar();
      /* A CAPTURA DO PONTEIRO NÃO ENTRA AQUI — e essa é a linha mais
         importante do arquivo. setPointerCapture redireciona os eventos de
         ponteiro para o elemento que capturou, e o click que nasce deles vai
         junto: com a captura tomada no pointerdown, o clique num card cai no
         .focus-track e NUNCA chega ao <a>. Os seis links ficam mortos para
         quem usa mouse, sem erro nenhum no console. Medido: com captura o
         click chega em ".focus-track"; sem captura, em ".focus-img", dentro
         do link. Ela entra no primeiro pointermove que passa do limiar —
         quando já é arrasto e o clique não deve mesmo acontecer. */
    });

    trilho.addEventListener('pointermove', function (e) {
      if (!arrastando || e.pointerId !== ponteiro) return;
      var dx = e.clientX - x0;
      if (Math.abs(dx) > andou) andou = Math.abs(dx);
      if (!capturado && andou > 6) {
        capturado = true;
        /* Agora sim: daqui em diante o arrasto sobrevive ao ponteiro sair do
           trilho, e o clique já está condenado pelo limiar. */
        try { trilho.setPointerCapture(ponteiro); } catch (err) {}
      }
      /* Deslocamento absoluto desde o pointerdown, não soma de deltas: um
         quadro perdido não vira erro acumulado. */
      trilho.scrollLeft = s0 - dx;
    });

    function soltar(e) {
      if (!arrastando) return;
      if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
      arrastando = false;
      trilho.classList.remove('is-dragging');
      if (capturado) {
        capturado = false;
        try { trilho.releasePointerCapture(ponteiro); } catch (err) {}
      }
      /* Encaixa no que já está mais perto do centro — pintar() acabou de
         elegê-lo. O .is-free continua até este deslize assentar. */
      irPara(ativo);
    }

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

    /* Limiar de 6px: um clique sempre treme um pouco, um arrasto de verdade
       passa longe disso. Em fase de CAPTURA para chegar antes do <a> — sem
       isso, soltar a fila sobre um card abriria o link dele. */
    trilho.addEventListener('click', function (e) {
      if (andou > 6) { e.preventDefault(); e.stopPropagation(); }
    }, true);

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

    function indiceDe(no) {
      while (no && no !== trilho) {
        var i = itens.indexOf(no);
        if (i > -1) return i;
        no = no.parentNode;
      }
      return -1;
    }

    /* Só o eixo da fila. Cima e baixo continuam rolando a PÁGINA — quem
       aperta seta para baixo quer descer, não andar de card. */
    trilho.addEventListener('keydown', function (e) {
      var alvo;
      if (e.key === 'ArrowRight') alvo = ativo + 1;
      else if (e.key === 'ArrowLeft') alvo = ativo - 1;
      else if (e.key === 'Home') alvo = 0;
      else if (e.key === 'End') alvo = itens.length - 1;
      else return;

      e.preventDefault();
      if (alvo < 0) alvo = 0;
      if (alvo > itens.length - 1) alvo = itens.length - 1;
      irPara(alvo);

      /* Se quem está com o foco é um card, o foco anda junto — senão a
         próxima tabulação voltaria para o card que saiu de cena.
         preventScroll para o navegador não dar o pulo seco antes do deslize;
         quem rola é o irPara acima. */
      if (indiceDe(e.target) > -1) {
        var link = itens[alvo].querySelector('.focus-card');
        if (link) { try { link.focus({ preventScroll: true }); } catch (err) { link.focus(); } }
      }
    });

    /* Chegar por Tab também centraliza: sem isto o navegador traz o card
       focado para a borda do trilho e o encaixe o puxa de volta em seguida. */
    trilho.addEventListener('focusin', function (e) {
      var i = indiceDe(e.target);
      if (i > -1 && i !== ativo) irPara(i);
    });

    /* ------------------------------------------------------------ controles */
    /* O foco fica no botão de propósito: quem clica quer clicar de novo, e
       roubar o foco para o card quebraria a repetição. */

    if (antes)  antes.addEventListener('click',  function () { irPara(ativo - 1); });
    if (depois) depois.addEventListener('click', function () { irPara(ativo + 1); });

    for (var p = 0; p < pontos.length; p++) {
      (function (i) {
        pontos[i].addEventListener('click', function () { irPara(i); });
      })(p);
    }

    pintar();
  }

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

Uma fila de cards retrato que encaixa sempre com um deles no meio da tela. O card centrado está em tamanho e brilho cheios; os vizinhos aparecem menores e mais escuros, em proporção à distância que os separa do centro, sempre com um pedaço à mostra dos dois lados. A fila anda por arrasto de mouse, por gesto de toque, pelos dois botões do cabeçalho, pelos seis pontos do rodapé e pelas setas do teclado. Abaixo dela, a descrição do card ativo troca com blur, ao lado de um contador 02 / 06.

O olho não escolhe onde olhar: a escala e o brilho escolhem por ele, e o resto da fila fica visível como promessa em vez de sumir. É a forma para uma lista em que um item por vez merece atenção inteira mas os outros precisam continuar existindo — um cardápio de frentes de trabalho, uma linha de produtos, um conjunto de destinos. Uma grade comum daria os seis com o mesmo peso e obrigaria a pessoa a decidir sozinha por onde começar; uma lista vertical entregaria o primeiro e enterraria o sexto. Aqui a hierarquia é temporária e reversível: o item em foco é o que a pessoa acabou de escolher, e escolher outro custa um gesto.

Terceiro carrossel da categoria mídia e o primeiro com encaixe no centro — os outros dois alinham à coluna.


Quando usar

Quando não usar

Contra os vizinhos

Precisa de Use
Um item por vez em foco, com os outros à vista e cada um com sua página este
Muitos itens equivalentes, alinhados à coluna, sem hierarquia midia/galeria-scroll-horizontal
Fila que não para e não se navega midia/carrossel-perfis, prova/carrossel-depoimentos
Poucos itens, todos visíveis, o escolhido expandindo em largura listas/leque-paineis
Card que vira diálogo de tela cheia com texto longo overlay/card-expansivel-fullscreen
Três blocos fixos com texto dentro da foto, sem movimento conteudo/pilares-texto-na-foto

galeria-scroll-horizontal e este componente parecem a mesma coisa e são opostos na intenção: lá todos os cards têm o mesmo peso e a ordem é a única hierarquia; aqui existe um item ativo, e é dele que a dobra fala.


Padrão editorial

Slot Regra
Olho (.eyebrow) 1 a 3 palavras. Nomeia a lista: "Soluções", "Frentes", "Onde atuamos".
Título (.sec-title) 1 linha, uma palavra em <em>. Precisa dizer que existem várias portas: "Uma frente para cada momento".
Pílula de categoria (.pill.focus-tag) 2 a 4 palavras, sempre do mesmo conjunto fechado. É o nome do serviço; se cada card inventar a própria classificação, a pílula deixa de classificar. Acima de 30 caracteres a pílula quebra em duas linhas no desktop — no celular ela já perde a borda de propósito (ver gotchas).
Título do card (.focus-title) 4 a 8 palavras, até 3 linhas em 15ch. É a promessa, não o nome: "Carteira desenhada para o seu horizonte", não "Consultoria". O nome já está na pílula acima.
Legenda do rodapé (.focus-note) 90 a 140 caracteres, uma ou duas frases. É o que o título do card não cabe: o mecanismo, o entregável, a ressalva. Fora dessa faixa a barra de pontos passa a subir e descer entre um card e outro.
alt de cada foto Descreve a foto, não o serviço — a pílula e o título já estão dentro da imagem e seriam anunciados duas vezes.
href de cada card A página do serviço. Um card que leva a lugar nenhum é pior que um card a menos.
Rótulo dos pontos aria-label="Ir para o card N", com o N batendo com o contador visível. Repetir o título do serviço ali seria eco: a lista já anuncia os seis itens e cada card tem nome.

Quantidade: 5 a 8. Com 4 a fila mal sai do lugar em tela larga. Acima de 8, os pontos viram uma régua ilegível e a pessoa perde a noção de onde está — e aí o componente certo é uma página de índice.

Escreva as seis legendas juntas, em paralelo. Elas ocupam o mesmo lugar e se substituem: se uma tem 92 caracteres e a outra 140, a troca muda a altura do bloco e a barra de pontos pula. O min-height do CSS reserva três linhas para segurar isso, mas ele foi calibrado nessa faixa de comprimento.


Especificação das imagens

Proporção retrato, recortada em 4:5 pelo object-fit:cover
Mínimo 1040×1300 — o card chega a 520px de largura e a foto ainda faz scale(1.05) sob o ponteiro
Recorte o assunto tem de sobreviver a corte no topo e na base: 3:4 entrando em 4:5 perde cerca de 7% da altura; 2:3 perde 17%
Peso até 200 KB cada. São 5 a 8 na mesma dobra, e as três primeiras carregam de saída
Tom as fotos vão aparecer lado a lado com brilhos diferentes (100% no centro, 55% nos vizinhos). Escolha imagens de exposição parecida entre si — uma foto já escura vira mancha preta quando é a vizinha
Base os últimos 30% da altura ficam sob o véu da legenda. Não deixe rosto nem assunto ali

Por que ph-* e não ph-wide-* no preview. A caixa é 4:5 retrato. Os ph-* são 3:4 e perdem 7% da altura no recorte — perda realista. Os ph-wide-* são 16:9 e perderiam mais de metade da largura: o preview mostraria um enquadramento que ninguém entregaria de verdade.


Como usar

<link rel="stylesheet" href="estilo.css">
<script src="script.js" defer></script>

Cole markup.html, estilo.css e script.js. Depende do core (tokens, base, motion) e de mais nada.

A conta que centraliza — e por que não é o --inset dos irmãos

galeria-scroll-horizontal e carrossel-depoimentos sangram até a borda e alinham o primeiro item à coluna, com um recuo que refaz a conta do .wrap. Aqui isso não serve: o encaixe é no centro, e o card ativo precisa poder chegar ao meio da tela. O primeiro e o último só chegam lá se houver meia tela de sobra antes e depois deles:

padding-inline:max(0px,calc(50% - var(--focus-card) / 2));

Metade da seção menos metade do card. O 50% é da seção: o trilho tem de continuar filho direto dela. Medido em 1440×900, com --focus-card em 520px: o recuo dá 452,5px de cada lado, o scrollWidth fica em 4155px e o scrollLeft máximo em 2730px — exatamente o valor necessário para centralizar o último card (2731px, 1px de arredondamento). O Chrome conta o padding do fim no scrollWidth; se algum motor não contar, o último card para antes do centro e o sintoma é o botão "próximo" nunca desabilitar. Por isso o disabled dos botões é decidido pelo índice do card ativo, e não pelo scrollLeft.

scroll-padding não é necessário aqui, e isso é diferente do irmão que alinha ao início. Com scroll-snap-align:center o snapport é a caixa de padding inteira, e o centro dela já é o centro do trilho. Medido: o ponto onde o encaixe para e o alvo do scrollIntoView batem com menos de 1px de diferença; o valor computado de scroll-padding continua auto.

O que o script escreve — e o que ele não escreve

Nenhum transform sai do JavaScript. Num rAF preso ao scroll do trilho, o script lê a caixa de cada card, calcula k = |centro do card − centro do trilho| / passo, limita a 1, e escreve --focus-k no <li>. A escala e o brilho são duas linhas de CSS:

transform:scale(calc(1 - var(--focus-k,0) * var(--focus-scale)));
filter:brightness(calc(1 - var(--focus-k,0) * var(--focus-dim)));

O padrão do var() é 0, e essa escolha tem consequência: sem JavaScript, a fila fica inteira em tamanho e brilho cheios — um carrossel de encaixe comum, legível. Com 1 como padrão (o mais óbvio), a dobra sem script apareceria toda encolhida e escura.

O passo vem medido do DOM (rect[1].left − rect[0].left), não da soma de --focus-card com --focus-gap: assim o componente não precisa saber em que unidade o tema escreveu os dois.

O acesso por teclado

Camada O quê
Trilho focável tabindex="0" no <ul>. Região que rola tem de ser alcançável pelo teclado, e é onde as setas moram
Card focável de graça cada card é um <a> de verdade. Tab percorre os seis, Enter abre, :focus-visible acende a foto igual ao ponteiro
Setas ← e → andam de card em card, Home e End vão às pontas. Cima e baixo não são capturados: quem aperta seta para baixo quer descer a página
Foco centraliza chegando por Tab, o focusin traz o card focado para o centro — senão o navegador o encosta na borda e o encaixe o puxa de volta em seguida
Pontos e botões <button> de verdade. Os pontos marcam o ativo com aria-current="true", que é também o seletor do estilo: uma fonte de verdade só

Medido: End leva o scrollLeft a 2730 (o máximo), o contador a 06, o ponto 6 a aria-current e o botão "próximo" a disabled. Focar o quarto card por script deixa o trilho em 1638 contra um alvo de 1639.

Ganchos

Gancho O quê
[data-focus] a raiz. O script aceita vários carrosséis na mesma página
[data-focus-track] o <ul> que rola. Filho direto da seção, obrigatoriamente
[data-focus-prev] / [data-focus-next] os dois discos; disabled nas pontas
[data-focus-notes] o contêiner aria-live das legendas
[data-focus-dots] / [data-focus-now] a barra de pontos e o número do contador
--focus-card largura do card; a altura sai do aspect-ratio:4/5
--focus-gap vão entre cards; entra na conta da fatia do vizinho
--focus-scale quanto o vizinho encolhe (.14 padrão, .08 no celular, .05 sob movimento reduzido)
--focus-dim quanto o vizinho escurece (.45 padrão)
--focus-dur ritmo do zoom da foto sob o ponteiro (.55s)
--focus-k escrita pelo script, por card. Não escreva na mão
.is-free / .is-dragging / .is-swap encaixe suspenso · mão fechada no trilho · meio caminho da troca da legenda

Variações

Ver componente.json. As duas que mais mudam a leitura: --focus-card decide quantos cards aparecem, e --focus-dim decide se a dobra é um foco ou uma vitrine. Em seção clara, considere --focus-dim:.25 — escurecer muito uma foto clara lê como foto queimada, não como foto fora de foco.


Gotchas


Histórico

Projeto Onde Data O quê
autoral construído do zero na biblioteca 2026-09-03 —

Origem. O padrão — fila de cards grandes com um deles em foco no centro e os vizinhos menores e apagados dos dois lados — é recorrente em sites premium de produto: as vitrines da Apple e da Nike, os templates de carrossel do Framer, boa parte dos vencedores de Awwwards na categoria e-commerce. Não há referência única, e nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O que essas implementações têm em comum, e que aqui não veio junto, é o motor: quase todas movem a fila com transform escrito por JavaScript quadro a quadro, com inércia própria, e por isso perdem o gesto nativo de toque, o encaixe do sistema operacional e a barra de rolagem de quem usa trackpad.

Decisões de tradução para o contrato. O motor virou scroll-snap nativo com scroll-snap-align:center: o navegador rola, encaixa e trata o gesto, e a fila funciona antes de o script existir. O script ficou reduzido a medir a distância de cada card ao centro e escrever --focus-k (0 a 1) por item — a escala e o escurecimento são duas linhas de CSS lendo essa variável, com --focus-scale e --focus-dim como os dois parafusos públicos. O padrão do var() é 0, e não 1, para que a dobra sem JavaScript apareça inteira em tamanho cheio. Toda cor virou token (--light sobre a foto, --dark-rgb no véu, --accent no ponto ativo, --rule-2 na borda dos botões), a curva virou --ease, o canto virou --radius-media e o rótulo de categoria virou a primitiva .pill do core, retonalizada para a ilha escura pelo mesmo seletor duplo que o galeria-scroll-horizontal usa. O alinhamento, ao contrário dos dois irmãos que sangram, é ao centro e não à coluna: padding-inline:calc(50% - card/2) substitui o --inset e é o que permite ao primeiro e ao último card chegarem ao meio da tela.

A acessibilidade é a parte que o padrão comum quase nunca tem: trilho focável com ←, →, Home e End; focusin que centraliza o card alcançado por Tab; pontos que são <button> com aria-label e aria-current="true" — o mesmo atributo servindo de estado para o leitor de tela e de seletor para o estilo; legenda em aria-live="polite" escrita só quando o card ativo muda, com debounce de 150ms; contador marcado aria-hidden para não ecoar o que os pontos já dizem; role="list" no <ul>, porque o reset do core tira o list-style e sem ele o Safari deixa de anunciar "lista de 6 itens". E a captura do ponteiro adiada até depois do limiar de 6px, sem o que o clique nos seis links morre em silêncio.

Calibragem no primeiro uso. Espere ajustar --focus-card e --focus-dim — são as duas decisões que dependem do acervo de fotos do cliente: quanto o card ativo ocupa, e quanto os vizinhos apagam.