← Biblioteca componentes/midia/galeria-scroll-horizontal/ficha.md CódigoTela cheia
midia/galeria-scroll-horizontal

Galeria de cases em scroll horizontal

🟢 estavel complexidade média prefixo hgal- galeria carrossel scroll-horizontal portfolio cases imagem snap teclado

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

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

markup.html · 146 linhas · 7,2 KB

<!-- ==========================================================================
     GALERIA EM SCROLL HORIZONTAL — faixa de cases que rola para o lado
     Prefixo: hgal-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. O trilho, o encaixe e o hover são CSS puro.

     SANGRIA (contrato §4). O cabeçalho vive dentro do .wrap. O trilho é IRMÃO
     do .wrap, filho direto da seção — por isso já nasce com a largura da
     página, sem margem negativa e sem 100vw. O que alinha o primeiro card à
     borda da coluna é --hgal-inset, calculado no estilo.css a partir de --pad
     e --max. O trilho TEM de continuar filho direto da seção: a conta usa
     100% da largura dela.

     TECLADO. Cada card é um <a> de verdade — Tab, Enter e :focus-visible vêm
     de graça. O script.js acrescenta seta esquerda/direita, Home/End e os dois
     botões do cabeçalho, que são a única forma de navegar com mouse sem roda
     horizontal. Sem isso, metade das pessoas não passa do segundo card.

     ENTRADA. data-rv/data-d do core, nunca observer próprio. Os três primeiros
     cards escalonam; do quarto em diante cada um entra sozinho quando o trilho
     o traz para a tela, e delay ali só atrasaria o que a pessoa já pediu.
     ========================================================================== -->

<section class="sec-dark sec-pad hgal" id="trabalhos" data-hgal>

  <div class="wrap hgal-head">
    <div class="hgal-head-left">
      <span class="eyebrow" data-rv>Casos atendidos</span>
      <h2 class="sec-title ttl">Estruturas <em>recentes</em></h2>
    </div>

    <!-- O script esconde este bloco inteiro quando o trilho não tem para onde
         rolar (poucos cards, tela larga). Dica e botões somem juntos: os três
         só existem porque há mais faixa do que cabe. -->
    <div class="hgal-nav" data-hgal-nav data-rv data-d="1">
      <p class="hgal-hint">
        Arraste para ver
        <svg class="hgal-hint-arrow" width="20" height="14" viewBox="0 0 22 14" fill="none" aria-hidden="true" focusable="false">
          <path d="M1 7H20M20 7L14 1M20 7L14 13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
      </p>
      <button class="btn-disc hgal-btn" type="button" data-hgal-prev aria-label="Ver os casos anteriores">
        <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 hgal-btn" type="button" data-hgal-next aria-label="Ver os próximos casos">
        <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>
  </div>

  <!-- Lista de verdade: o leitor de tela anuncia "5 itens" antes do primeiro
       card, que é a informação que o scroll horizontal esconde de quem não vê
       a faixa continuar fora da tela. -->
  <ul class="hgal-track" data-hgal-track>

    <li class="hgal-item" data-rv>
      <a class="hgal-card" href="#case-altamira">
        <span class="hgal-media">
          <img class="hgal-img" src="../../../showroom/demo/ph-wide-1.webp" alt="Casal em um vinhedo ao entardecer" loading="lazy" decoding="async">
          <span class="hgal-badge" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" focusable="false">
              <path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="hgal-cap">
            <span class="hgal-name">Altamira</span>
            <span class="pill hgal-type">Holding familiar</span>
          </span>
        </span>
      </a>
    </li>

    <li class="hgal-item" data-rv data-d="1">
      <a class="hgal-card" href="#case-meridian">
        <span class="hgal-media">
          <img class="hgal-img" src="../../../showroom/demo/ph-wide-2.webp" alt="Homem observando a cidade pela janela" loading="lazy" decoding="async">
          <span class="hgal-badge" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" focusable="false">
              <path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="hgal-cap">
            <span class="hgal-name">Meridian</span>
            <span class="pill hgal-type">Sucessão</span>
          </span>
        </span>
      </a>
    </li>

    <li class="hgal-item" data-rv data-d="2">
      <a class="hgal-card" href="#case-northbay">
        <span class="hgal-media">
          <img class="hgal-img" src="../../../showroom/demo/ph-wide-3.webp" alt="Veleiro em mar aberto" loading="lazy" decoding="async">
          <span class="hgal-badge" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" focusable="false">
              <path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="hgal-cap">
            <span class="hgal-name">Northbay</span>
            <span class="pill hgal-type">Estrutura offshore</span>
          </span>
        </span>
      </a>
    </li>

    <li class="hgal-item" data-rv>
      <a class="hgal-card" href="#case-quadrant">
        <span class="hgal-media">
          <img class="hgal-img" src="../../../showroom/demo/ph-wide-4.webp" alt="Sala de reunião vazia com vista para a cidade" loading="lazy" decoding="async">
          <span class="hgal-badge" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" focusable="false">
              <path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="hgal-cap">
            <span class="hgal-name">Quadrant</span>
            <span class="pill hgal-type">Venda de participação</span>
          </span>
        </span>
      </a>
    </li>

    <li class="hgal-item" data-rv>
      <a class="hgal-card" href="#case-solvane">
        <span class="hgal-media">
          <img class="hgal-img" src="../../../showroom/demo/ph-wide-5.webp" alt="Casal assistindo a uma partida de tênis em quadra de saibro" loading="lazy" decoding="async">
          <span class="hgal-badge" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" focusable="false">
              <path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="hgal-cap">
            <span class="hgal-name">Solvane</span>
            <span class="pill hgal-type">Previdência corporativa</span>
          </span>
        </span>
      </a>
    </li>

  </ul>
</section>

estilo.css · 229 linhas · 10,3 KB

/* ==========================================================================
   GALERIA EM SCROLL HORIZONTAL — prefixo hgal-
   Faixa de cases que rola na horizontal, com encaixe (scroll-snap) e barra
   escondida nos três motores.

   Tokens usados:
     --pad --max          conta do recuo que alinha o trilho à coluna
     --display --ease     tipografia e curva do projeto
     --accent --dark      distintivo (badge): fundo de marca, ícone escuro
     --dark-rgb           véu da legenda sobre a foto
     --light              texto sobre a foto (ilha escura, ver abaixo)
     --radius-media       canto do card
     --radius-pill        disco dos botões e pílula do tipo
     --rule --rule-2      borda do card em repouso e no hover
     --bg --fg-2 --fg-muted   fundo de carregamento, botões e dica

   O componente é uma ILHA ESCURA: o card é foto, e o texto vive dentro dela.
   Por isso o que está sobre a imagem lê --light, e não --fg — assim a mesma
   faixa roda numa seção .sec-paper sem virar texto escuro sobre véu escuro.
   O que está fora da imagem (dica, botões) continua lendo o escopo de tom.
   ========================================================================== */

.hgal{
  /* Largura do card. O clamp é o que faz a faixa mostrar ~2,7 cards no
     desktop e ~1,2 no celular — sempre com um pedaço do próximo à mostra,
     que é o sinal de que existe mais coisa para o lado. */
  --hgal-card:clamp(280px,36vw,460px);
  --hgal-gap:clamp(14px,2vw,22px);
  /* --dur do core é .9s: certo para entrada de seção, lento para a foto
     responder ao ponteiro. Duração de interação é local. */
  --hgal-dur:.6s;

  /* SANGRIA SEM MARGEM NEGATIVA (contrato §4).
     O trilho é irmão do .wrap, então já ocupa a página inteira. O que falta
     é fazer o primeiro card nascer exatamente na borda da coluna: é este
     recuo. Ele repete a conta do .wrap (metade da sobra, mais o respiro),
     mas aplicado ao trilho — não sobrescreve o .wrap, não usa 100vw e não
     puxa nada com margem negativa.
       tela estreita  ->  a coluna já encosta nas bordas, vale --pad
       tela larga     ->  (100% - --max) / 2 + --pad
     O 100% é a largura da SEÇÃO: o trilho tem de ser filho direto dela. */
  --hgal-inset:max(var(--pad),calc((100% - var(--max)) / 2 + var(--pad)));
}

/* ---------------------------------------------------------------- cabeçalho */
/* Título à esquerda, dica e botões à direita, alinhados pela base. Não usa
   .sec-head do core de propósito: .sec-head é cabeçalho CENTRADO, e aqui os
   dois lados precisam se olhar. */
.hgal-head{
  display:flex;align-items:flex-end;justify-content:space-between;
  gap:clamp(18px,4vw,48px);
  margin-bottom:clamp(32px,5vh,54px);
}
.hgal-head-left{min-width:0}

.hgal-nav{display:flex;align-items:center;gap:12px;flex:0 0 auto;padding-bottom:6px}
/* O script põe [hidden] quando não há o que rolar. A regra é obrigatória:
   display vindo de classe vence o [hidden] da folha do navegador. */
.hgal-nav[hidden]{display:none}

.hgal-hint{
  display:inline-flex;align-items:center;gap:10px;
  font-family:var(--display);font-weight:700;font-size:11px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--fg-muted);
  white-space:nowrap;
}
/* A cutucada a cada 2.2s. Curva do projeto, não "ease" cravado: o vaivém
   fica um tico mais seco que no original, e é o preço de não escrever
   easing na mão (contrato §3). */
.hgal-hint-arrow{flex:0 0 auto;animation:hgalNudge 2.2s var(--ease) infinite}
@keyframes hgalNudge{
  0%,100%{transform:translateX(0)}
  50%{transform:translateX(8px)}
}

/* Disco de ícone. Não é .btn: .btn é pílula de TEXTO com padding fixo, e
   espremer 42px nela seria brigar com a primitiva em vez de usá-la. Se um
   terceiro componente pedir disco de ícone, promova ao core (contrato §6). */
/* O disco de ícone é a primitiva .btn-disc do core (core/base.css): forma,
   cor, hover e o estado desabilitado vêm de lá, e o markup traz as duas
   classes. Esta pasta escrevia as mesmas seis linhas — era a primeira de cinco
   cópias idênticas, e o §6 manda promover na terceira. */
/* Ponta da faixa: o botão apaga em vez de sumir, para a dupla não dançar. */

/* ---------------------------------------------------------------- trilho */
.hgal-track{
  display:flex;gap:var(--hgal-gap);
  margin:0;list-style:none;
  padding-inline:var(--hgal-inset);
  /* Respiro vertical mínimo: sem ele o overflow corta o anel de foco do
     card, que o core desenha 3px para fora da caixa. */
  padding-block:6px 10px;

  overflow-x:auto;overflow-y:hidden;
  scroll-snap-type:x mandatory;
  /* Sem isto o encaixe alinha o card na borda da TELA e não na borda da
     coluna: o padding do trilho não entra sozinho na conta do snap. */
  scroll-padding-inline:var(--hgal-inset);
  /* Chegou na ponta, para de arrastar a página junto. */
  overscroll-behavior-x:contain;
  -webkit-overflow-scrolling:touch;

  /* Barra escondida nos três motores. A navegação fica por conta do gesto,
     dos dois botões e das setas do teclado — nunca só da barra. */
  scrollbar-width:none;
  -ms-overflow-style:none;
}
.hgal-track::-webkit-scrollbar{width:0;height:0;display:none}

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

/* ---------------------------------------------------------------- card */
.hgal-card{
  display:block;position:relative;
  background:var(--bg);
  border:1px solid var(--rule);
  border-radius:var(--radius-media);
  overflow:hidden;
  transition:border-color .4s var(--ease);
}
/* Era #3a3a36 no original: um degrau neutro acima da linha de repouso, que
   é exatamente o que --rule-2 significa. A cor de marca fica para o
   distintivo, senão o hover acende duas coisas ao mesmo tempo. */
.hgal-card:hover{border-color:var(--rule-2)}

/* Não usa .glass / .glass-hover de propósito: são cinco cards dentro de um
   contêiner que rola, e backdrop-filter em cada um custa quadro em scroll
   de toque. Fora que .glass assume fundo escuro, e esta faixa também roda
   em seção clara. Borda e foto resolvem sem cobrar nada. */

.hgal-media{display:block;position:relative;aspect-ratio:4/3;overflow:hidden}

.hgal-img{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  /* grayscale parcial, não total: a faixa fica coesa em repouso e o card
     sob o ponteiro ganha cor sem virar outra imagem. */
  filter:grayscale(.3);
  transition:filter var(--hgal-dur) var(--ease);
}
/* :focus-visible junto do :hover — quem chega pelo teclado vê o mesmo card
   acender que quem chega pelo mouse. */
/* 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 ficou em cor e brilho, que não mudam
   a geometria de nada. */
.hgal-card:hover .hgal-img,
.hgal-card:focus-visible .hgal-img{filter:grayscale(0)}

/* Distintivo: disco na cor de marca com a seta de "abrir". Era
   rgba(47,107,255,.9) cravado; agora é --accent a 90% de opacidade, e o
   ícone lê --dark — a mesma dupla do .btn-primary do core. */
.hgal-badge{
  position:absolute;top:14px;right:14px;z-index:2;
  width:38px;height:38px;
  display:grid;place-items:center;
  color:var(--dark);
  background:color-mix(in srgb,var(--accent) 90%,transparent);
  border-radius:var(--radius-pill);
  backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
  opacity:0;transform:translateY(-6px) scale(.85);
  transition:opacity .35s var(--ease),transform .35s var(--ease);
}
.hgal-card:hover .hgal-badge,
.hgal-card:focus-visible .hgal-badge{opacity:1;transform:none}

/* Legenda DENTRO da foto, ancorada embaixo, com véu em gradiente mascarado
   (contrato §6 — texto sobre imagem). No original, nome e tipo eram uma
   tarja abaixo da foto; virou legenda porque o contrato pede, e porque o
   card passa a ser uma imagem só — sem rodapé para desalinhar entre um
   card de nome curto e um de nome longo. */
.hgal-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  display:flex;flex-direction:column;align-items:flex-start;gap:11px;
  padding:56px 22px 20px;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.92) 0%,
    rgba(var(--dark-rgb),.55) 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%);
}

.hgal-name{
  font-family:var(--display);font-weight:700;
  font-size:clamp(17px,1.5vw,20px);line-height:1.2;letter-spacing:-.02em;
  color:var(--light);
}

/* Reaproveita a .pill do core — forma, respiro e tracking vêm de lá. 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 .hgal-type têm a mesma especificidade, e o
   contrato §6 proíbe depender de qual folha carregou primeiro. */
.hgal-cap .hgal-type{
  color:var(--light);
  border-color:color-mix(in srgb,var(--light) 32%,transparent);
}

/* ---------------------------------------------------------------- movimento reduzido */
/* Some o vaivém da seta, o zoom da foto e o salto do distintivo. O que fica
   é troca de cor e de opacidade — nenhuma das duas é deslocamento, e sem
   elas o card deixaria de responder ao ponteiro. */
@media(prefers-reduced-motion:reduce){
  .hgal-hint-arrow{animation:none}
  .hgal-img{transition-duration:.01ms}
  .hgal-badge{transform:none}
}

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não existe hover: o distintivo ficaria invisível para sempre
   em celular e tablet. Aqui ele nasce aceso. */
@media(hover:none){
  .hgal-badge{opacity:1;transform:none}
}

/* ---------------------------------------------------------------- mobile */
@media(max-width:860px){
  .hgal-head{flex-direction:column;align-items:flex-start;gap:20px}
  .hgal-nav{padding-bottom:0}
  /* Em tela de toque arrastar é o gesto óbvio: a dica vira ruído, e os
     botões bastam para quem prefere tocar num alvo. */
  .hgal-hint{display:none}
}
@media(max-width:520px){
  .hgal{--hgal-card:min(78vw,320px)}
  .hgal-cap{padding:46px 16px 16px;gap:9px}
  .hgal-badge{top:11px;right:11px;width:34px;height:34px}
}

script.js · 141 linhas · 5,7 KB

/* ==========================================================================
   GALERIA EM SCROLL HORIZONTAL — teclado e setas de navegação.

   O trilho, o encaixe e o hover são CSS e não passam por aqui. Este script
   existe por um motivo só: sem ele, quem não tem trackpad horizontal nem
   tela de toque não chega ao segundo card. Roda vertical de mouse não move
   contêiner que rola no eixo x — a página rola no lugar dele.

   Faz três coisas:
     1. seta esquerda/direita, Home e End andam de card em card;
     2. os dois botões do cabeçalho rolam um card por clique;
     3. o cabeçalho de navegação some quando não há o que rolar, e cada
        botão apaga ao chegar na sua ponta.

   O deslocamento é sempre scrollIntoView, nunca scrollLeft na mão: assim
   quem decide onde o card para continua sendo o scroll-padding do CSS.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-hgal]');
  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-hgal-track]');
    if (!trilho) return;

    var cards = [].slice.call(trilho.querySelectorAll('.hgal-card'));
    if (!cards.length) return;

    var nav    = raiz.querySelector('[data-hgal-nav]');
    var antes  = raiz.querySelector('[data-hgal-prev]');
    var depois = raiz.querySelector('[data-hgal-next]');
    var pedido = 0;

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

    function trazer(card) {
      if (!card) return;
      var como = { behavior: reduzido() ? 'auto' : 'smooth',
                   inline: 'start',
                   block: 'nearest' };   /* block:nearest = a página não pula */
      try { card.scrollIntoView(como); } catch (e) { card.scrollIntoView(); }
    }

    /* Primeiro card, no sentido pedido, que ainda NÃO está encostado na borda
       da coluna. A conta é toda em coordenada de tela: o marco é onde um card
       encostado começa, e comparar contra ele dispensa saber o scrollLeft.

       Lê paddingLeft, não scrollPaddingLeft: o valor computado de
       scroll-padding guarda a função de math inteira ("max(71px, 50% - …)")
       e nenhum parseFloat sobrevive a isso. Os dois são o mesmo
       --hgal-inset no estilo.css — se um mudar, mude o outro junto. */
    function vizinho(sentido) {
      var marco = trilho.getBoundingClientRect().left +
                  (parseFloat(getComputedStyle(trilho).paddingLeft) || 0);
      var i;
      if (sentido > 0) {
        for (i = 0; i < cards.length; i++) {
          if (cards[i].getBoundingClientRect().left > marco + 4) return cards[i];
        }
        return cards[cards.length - 1];
      }
      for (i = cards.length - 1; i >= 0; i--) {
        if (cards[i].getBoundingClientRect().left < marco - 4) return cards[i];
      }
      return cards[0];
    }

    /* ---------------------------------------------------------- estado */
    /* Preso ao scroll do trilho: nunca calcula dentro do listener. */

    function pintar() {
      pedido = 0;
      var sobra = trilho.scrollWidth - trilho.clientWidth;
      var rola  = sobra > 4;
      if (nav) nav.hidden = !rola;
      if (antes)  antes.disabled  = !rola || trilho.scrollLeft <= 4;
      if (depois) depois.disabled = !rola || trilho.scrollLeft >= sobra - 4;
    }

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

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

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

    /* Tab também rola: sem isto o navegador leva o foco para um card fora da
       tela e o salto é seco. Um só ponto de rolagem para foco, venha ele de
       Tab ou das setas. */
    trilho.addEventListener('focusin', function (e) {
      var alvo = e.target;
      while (alvo && alvo !== trilho) {
        if (cards.indexOf(alvo) > -1) { trazer(alvo); return; }
        alvo = alvo.parentNode;
      }
    });

    /* Só o eixo da faixa. 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 i = cards.indexOf(e.target);
      if (i < 0) return;

      var alvo = null;
      if (e.key === 'ArrowRight') alvo = cards[i + 1];
      else if (e.key === 'ArrowLeft') alvo = cards[i - 1];
      else if (e.key === 'Home') alvo = cards[0];
      else if (e.key === 'End') alvo = cards[cards.length - 1];
      else return;

      if (!alvo || alvo === e.target) return;
      e.preventDefault();
      /* preventScroll para o navegador não dar o pulo seco antes do nosso
         deslizar; quem rola é o focusin logo acima. */
      try { alvo.focus({ preventScroll: true }); } catch (err) { alvo.focus(); }
    });

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

    if (antes)  antes.addEventListener('click',  function () { trazer(vizinho(-1)); });
    if (depois) depois.addEventListener('click', function () { trazer(vizinho(1)); });

    pintar();
  }

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

Uma faixa de cases que sangra até as duas bordas da página e rola para o lado, com encaixe (scroll-snap) e barra de rolagem escondida. Cada card é uma foto 4:3 em cinza parcial que ganha cor e um passo de zoom sob o ponteiro; no canto de cima aparece um distintivo redondo na cor de marca, e no rodapé da própria imagem ficam o nome e o tipo do trabalho. O cabeçalho traz título à esquerda e, à direita, a dica "Arraste para ver" com uma seta que cutuca a cada 2,2s — mais dois botões que fazem a faixa andar de card em card.

É a segunda entrada da categoria mídia e a primeira galeria horizontal da biblioteca.


Quando usar

Quando não usar

Contra os vizinhos

Precisa de Use
Coleção de fotos que valem juntas, sem destino próprio midia/galeria-pasta
Poucos itens, cada um com título e descrição listas/leque-paineis
Muitos itens com foto e nome, cada um com sua página este

galeria-pasta é uma pilha que abre; aqui nada abre — a faixa já está aberta e só anda. leque-paineis mostra um item por vez em largura variável; aqui todos os cards têm o mesmo tamanho e a hierarquia é a ordem, não o foco.


Padrão editorial

Slot Regra
Olho (.eyebrow) 2 a 4 palavras. Diz o recorte da lista: "Casos atendidos", "Estruturas selecionadas". A palavra "selecionadas" é útil — avisa que a faixa não é a carteira inteira.
Título (.sec-title) 1 linha, uma palavra em <em>.
Dica (.hgal-hint) 3 a 4 palavras, imperativo. "Arraste para ver". Não escreva "role para o lado": é instrução de mecanismo, não convite.
Nome (.hgal-name) 1 a 3 palavras. O cliente ou o projeto. Nome comprido quebra em duas linhas e come a foto.
Tipo (.pill.hgal-type) 1 a 3 palavras, sempre do mesmo conjunto fechado em todos os cards: "Holding familiar", "Sucessão", "Estrutura offshore". Se cada card inventa a própria categoria, a pílula deixa de classificar.
alt de cada foto Descreve a foto, não o case — o nome do cliente já está na legenda logo abaixo e seria anunciado duas vezes.
href de cada card Aponta para a página do case. Um card que leva a lugar nenhum é pior que um card a menos.

Quantidade: 4 a 8. Abaixo de 4 não há faixa. Acima de 8, quem chega ao fim rolou por mais de duas telas e meia sem um índice — e a faixa vira um túnel.

Escreva os nomes juntos. A legenda é o único texto do card, e nomes de comprimentos muito diferentes fazem a linha de base dançar de um card para o outro.


Especificação das imagens

Proporção paisagem, recortada em 4:3 pelo object-fit:cover
Mínimo 1600×1200 — o card chega a 460px de largura e a foto ainda faz scale(1.06) no hover
Recorte o assunto tem de sobreviver a corte nas laterais: 16:9 entrando numa caixa 4:3 perde cerca de um quarto da largura
Peso até 220 KB cada. São 4 a 8 na mesma dobra
Tom contraste médio. A foto entra em grayscale(.3): escolha imagens que funcionem pela forma, porque a cor só aparece inteira sob o ponteiro
Base os últimos 25% da altura ficam sob o véu da legenda. Não deixe o assunto ali

Por que ph-wide-* no preview. A caixa é 4:3 paisagem. Os ph-wide-* são 16:9 e perdem as laterais no recorte — perda pequena e realista. Os ph-* são retrato 3:4 e perderiam mais da metade da altura para caber em 4:3: o preview mostraria um recorte que ninguém entregaria de verdade. São cinco ph-wide-* na pasta de demonstração, e por isso o padrão tem cinco cards — repetir foto num portfólio lê como erro, não como placeholder.


Como usar

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

A sangria — como o trilho sai da coluna

O contrato (§4) proíbe margem negativa e proíbe mexer na coluna. A solução aqui tem duas partes:

  1. O trilho é irmão do .wrap, não filho. O cabeçalho fica dentro do .wrap e recebe a coluna; o <ul class="hgal-track"> é filho direto da <section> e por isso já nasce com a largura da página. Nada precisa ser puxado para fora de lugar nenhum.

  2. O primeiro card se alinha à coluna por um recuo próprio, não por margem:

--hgal-inset:max(var(--pad),calc((100% - var(--max)) / 2 + var(--pad)));

É a mesma conta que o .wrap faz — metade da sobra da página, mais o respiro lateral — só que aplicada como padding-inline do trilho. Em tela estreita a sobra é negativa e o max() devolve --pad, que é exatamente onde a coluna encosta. O trilho lê os mesmos --pad e --max do tema: mudou a coluna, a faixa acompanha sozinha.

O mesmo valor vai em scroll-padding-inline, senão o encaixe alinha os cards na borda da tela e não na da coluna — e o scrollIntoView do script erra pelo mesmo tanto.

O 100% da conta é a largura da seção. Se o trilho for parar dentro de outro contêiner com respiro próprio, a conta passa a medir a caixa errada e o alinhamento quebra sem erro nenhum no console.

O acesso por teclado

O original não tinha nenhum: a faixa só andava com gesto de trackpad ou de toque. Quem usa mouse sem roda horizontal, teclado ou leitor de tela ficava no primeiro card — roda vertical não move contêiner que rola no eixo x, a página rola no lugar dele. São três camadas:

Camada O quê
Card focável de graça cada card é um <a> de verdade. Tab percorre a faixa, Enter abre, :focus-visible acende a foto igual ao hover — e o distintivo aparece junto
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
Botões os dois discos do cabeçalho rolam um card por clique. São a única saída para quem navega só com mouse; somem quando não há o que rolar

O deslocamento é sempre scrollIntoView({inline:'start', block:'nearest'}), nunca scrollLeft na mão — assim quem decide onde o card para continua sendo o scroll-padding do CSS. behavior vira auto sob prefers-reduced-motion, lendo LIB.reduce do core. O block:'nearest' é o que impede a página inteira de saltar na vertical a cada seta.

Quando o foco entra num card por Tab, o script rola a faixa até ele em vez de deixar o navegador dar o pulo seco. Nas setas o foco vai com preventScroll:true justamente para o pulo não acontecer antes do deslizar.

Ganchos

Gancho O quê
[data-hgal] a raiz. O script aceita várias faixas na mesma página
[data-hgal-track] o <ul> que rola. Tem de ser filho direto da seção
[data-hgal-nav] dica e botões. O script põe [hidden] quando não há o que rolar
[data-hgal-prev] / [data-hgal-next] os dois discos. disabled em cada ponta
--hgal-card largura do card
--hgal-gap vão entre cards
--hgal-dur ritmo do hover da foto (.6s)
--hgal-inset o recuo da sangria. Só mexa se souber o que a conta faz

Variações

Variação Como
Largura do card --hgal-card no .hgal. Sempre deixe um pedaço do próximo à mostra
Sem pílula de tipo apague o <span class="pill hgal-type">
Grão sobre a foto <span class="grain"></span> dentro de .hgal-media — a primitiva do core já vem posicionada
Em seção clara troque .sec-dark por .sec-paper. O card não muda: o texto sobre a foto lê --light e o véu lê --dark-rgb, os dois independentes do escopo de tom
Cards que não são link troque <a> por <button> e tire o distintivo de seta. Não use <div>: o teclado inteiro depende do elemento ser focável por natureza

Gotchas

Como confirmar sem se enganar, pelo console do painel:

js const t = document.querySelector('.hgal-track'); t.querySelectorAll('.hgal-card')[1] .scrollIntoView({behavior:'auto', inline:'start', block:'nearest'}); t.scrollLeft; // > 0 significa que a lógica está certa

Se 'auto' rola e 'smooth' não, é o ambiente. A mesma armadilha atinge qualquer componente que dependa de rolagem suave.


Histórico

Projeto Onde Data O quê
Gabriel Rocha Studio gabriel-rocha-studio-2.html #trabalhos 2026-08 Origem. Site autoral, rodou uma vez. Classes sem prefixo (.case, .ph, .name, .type), cores cruas, entrada por GSAP, sem acesso por teclado.

O que mudou na extração:

No original Aqui Por quê
.case, .ph, .name, .type, .work-* tudo sob hgal- .name e .type colidem no primeiro projeto que juntar dois componentes
.case:hover{border-color:#3a3a36} --rule-2 é literalmente o que --rule-2 significa: um degrau neutro acima da linha de repouso
rgba(47,107,255,.9) no distintivo color-mix(in srgb,var(--accent) 90%,transparent) azul de um projeto só. O ícone passou a ler --dark, a mesma dupla do .btn-primary
rgba(6,7,10,.25) no véu rgba(var(--dark-rgb),…) mesmo motivo, e o véu virou o da legenda
Nome e tipo numa tarja abaixo da foto legenda dentro da imagem, com gradiente e blur mascarados contrato §6, texto sobre imagem. De quebra o card virou uma imagem só: nome curto e nome longo não desalinham mais entre cards
Tipo em texto solto à direita primitiva .pill do core, retonalizada para a ilha escura §6 — a biblioteca já tem rótulo em pílula
gsap.from('.case',{opacity:0,x:42,stagger:.1}) data-rv + data-d do core o contrato não aceita lib para entrada de elemento. Perdeu-se o deslize lateral; ganhou-se o blur de entrada que a biblioteca inteira usa
Tilt 3D por mousemove + GSAP nada rotação 3D presa ao ponteiro em card dentro de contêiner que rola é caro e briga com o encaixe. Se voltar um dia, é variação com nome, não padrão
@keyframes nudge global @keyframes hgalNudge nome de keyframe é global: nudge colide com o próximo componente que cutucar alguma coisa
animation:nudge 2.2s ease var(--ease) §3 proíbe curva escrita na mão. O vaivém ficou um tico mais seco
padding:0 var(--pad) clamp(50px,8vh,80px) no trilho .sec-pad na seção + recuo calculado §4: ritmo vertical é --sec-gap, não clamp solto. E o recuo lateral passou a acompanhar --max
border-radius:16px cravado --radius-media projeto flat entrega 0, projeto arredondado entrega o raio dele
Sem teclado <a> focável, setas, Home/End, dois botões mecanismo de gesto sozinho exclui mouse sem roda horizontal, teclado e leitor de tela
<a> soltos num <div> <ul> / <li> o leitor de tela anuncia "5 itens" antes do primeiro card — a informação que o scroll horizontal esconde
Fotos injetadas por JS de uma lista externa <img> no markup, placeholders locais dependência externa é proibida, e imagem que só existe depois do script não tem alt nem entra no HTML

Calibragem no primeiro uso. Espere ajustar a largura do card e a quantidade — as duas decisões que dependem do acervo de fotos do cliente.