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
- Portfólio de capas: cases, projetos, imóveis, unidades, edições — coisas que se reconhecem pela foto e pelo nome, e que continuam existindo depois da terceira.
- Quando a lista é longa demais para uma grade e curta demais para uma página própria. A faixa mostra 2 ou 3 e promete o resto sem ocupar altura.
- Como dobra de respiro entre duas seções densas: uma faixa horizontal quebra o ritmo vertical da página sem precisar de contraste de tom.
- Quando cada item tem destino próprio (uma página de case). O card é um
<a>; se não houver para onde ir, o componente promete algo que não entrega.
Quando não usar
- Menos de 4 cards. Em desktop largo eles cabem na tela, o trilho não sai do lugar, o script esconde a dica e os botões — e sobra um flex comum que uma grade faria melhor.
- Quando os itens precisam ser comparados. Scroll horizontal esconde o que está fora da tela; comparação exige tudo à vista.
- Quando o item tem texto. Aqui cabem duas linhas de legenda dentro da foto e nada mais. Item com descrição pede
listas/leque-paineis. - Acima da dobra. A faixa depende de um gesto lateral que ninguém faz antes de entender a página.
- Como única navegação para conteúdo importante. Faixa horizontal é vitrine; o índice completo tem de existir em outro lugar.
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: "Trabalhos selecionados", "Projetos 2026". A palavra "selecionados" é útil — avisa que a faixa não é o catálogo inteiro. |
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: "Identidade visual", "Site institucional", "Rebrand". 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:
-
O trilho é irmão do
.wrap, não filho. O cabeçalho fica dentro do.wrape 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. -
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
- As setas parecem quebradas no painel de preview do Claude Code, e não estão. O deslocamento usa
scrollIntoView({behavior:'smooth'}); o browser embarcado do painel ignorasmoothe não rola nada, enquanto'auto'funciona normalmente. Em navegador de verdade as setas andam.
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.
-
O trilho tem de ser filho direto da seção. O
100%de--hgal-insetmede a largura do pai. Embrulhou o trilho em outra caixa, o primeiro card desalinha da coluna e nada avisa. -
scroll-padding-inlinenão é decoração. Sem ele o encaixe e oscrollIntoViewparam os cards na borda da tela, colados na margem, enquanto o cabeçalho continua na coluna. O erro é discreto e constante. -
O script lê
paddingLeft, nãoscrollPaddingLeft— e isso é obrigatório. O valor computado descroll-paddingguarda a função de math inteira ("max(71.25px, 50% - 548.75px)"), porque a propriedade aceita porcentagem e só resolve na hora do uso.parseFloatnisso devolveNaN, o recuo vira zero e os dois botões passam a rolar para o card em que já estão — falha silenciosa, sem erro no console.padding-inlinecomputa em px e vale o mesmo número. Se você mudar um dos dois no CSS, mude o outro junto: é o único acoplamento entre a folha e o script. -
A barra de rolagem some nos três motores (
scrollbar-width,-ms-overflow-style,::-webkit-scrollbar). É a razão de os botões existirem: escondida a barra, some também o último indício visual de que há mais conteúdo. Se você tirar os botões, devolva a barra. -
Os cards fora da tela só revelam quando entram nela.
data-rvusaIntersectionObserver, que considera o recorte do contêiner que rola: card à direita da faixa não está intersectando, então entra com fade e blur no momento em que a pessoa o traz. É comportamento desejado — mas quem espera todos prontos de saída vai achar que travou. Por isso só os três primeiros escalonam comdata-d: do quarto em diante, delay atrasaria o que a pessoa acabou de pedir. -
loading="lazy"em todas as fotos é uma troca, não um consenso. A dobra está bem abaixo do topo e as fotos de fora da faixa custariam banda à toa. Em compensação, num arrasto muito rápido pode aparecer um card ainda em branco. Se isso incomodar no projeto, tire olazydos dois primeiros cards — nunca de todos. -
O texto sobre a foto lê
--light, não--fg. O card é uma ilha escura: dentro dele o fundo é sempre a foto sob véu escuro, mesmo que a seção seja clara. Se trocar por--fg, a faixa quebra no dia em que alguém a puser numa.sec-paper— e só ali. -
A pílula do tipo é a
.pilldo core, retonalizada..hgal-cap .hgal-typetem seletor duplo de propósito:.pille.hgal-typetêm a mesma especificidade, e o contrato §6 proíbe depender de qual folha carregou primeiro. Forma, respiro e tracking continuam vindo do core; só cor e borda mudam. -
overflow-y:hiddencorta o anel de foco. Opadding-block:6px 10pxdo trilho existe só para ooutlinede 2px com 3px de recuo caber. Tirou o padding, o foco do card fica cortado em cima e embaixo — e é o único sinal de onde o teclado está. -
Não usa
.glass. São 4 a 8 cards dentro de um contêiner que rola: umbackdrop-filterem cada custa quadro em scroll de toque, e.glassainda pressupõe fundo escuro. Borda--rulee a própria foto entregam a mesma leitura de graça. -
Não usa
.sec-head. A primitiva do core é cabeçalho centrado; este é de dois lados, com a dica olhando o título. Não é o mesmo objeto. -
Não usa
.btnnos discos..btné pílula de texto com padding fixo; espremer 42px nela seria brigar com a primitiva. Se um terceiro componente pedir disco de ícone, o caminho é promover ao core, não copiar esta regra (contrato §6). -
@media (hover:none)acende o distintivo. Sem ponteiro não existe hover, e sem essa regra o disco de marca ficaria invisível para sempre em celular e tablet. -
Sob
prefers-reduced-motiono hover não morre, encolhe. A seta para de cutucar, a foto para de dar zoom e o distintivo para de saltar — mas cor e opacidade continuam, senão o card deixa de responder ao ponteiro e vira uma foto morta.
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 |
Status experimental: rodou uma vez, num site autoral, e nesta forma normalizada ainda não rodou em lugar nenhum. Espere ajuste no primeiro uso real — principalmente na largura do card e na quantidade, que são as duas decisões que dependem do acervo de fotos do cliente. Rodando num projeto, vira estavel e esta tabela ganha a linha dele.