Um trilho horizontal de cards retrato: foto, categoria em cor de marca e título dentro da imagem. No clique, o card não abre um modal centralizado — ele cresce do lugar exato onde está até cobrir a tela, com o raio da quina indo a zero, e só quando termina de crescer o conteúdo entra: foto cheia de um lado, categoria, título, texto, tags e CTA do outro.
O que resolve editorialmente: uma coleção de casos em que cada item merece meia página de leitura, mas nenhum merece uma página inteira. A grade comum obriga a escolher entre mostrar tudo (e virar parede de texto) ou mostrar só o título (e não convencer ninguém). Aqui o card mostra o mínimo, o overlay mostra o suficiente, e a transição entre os dois deixa claro que é o mesmo item — que é a coisa que um modal centralizado nunca consegue dizer.
É o primeiro componente da categoria sobreposições.
Quando usar
- Coleção de 4 a 10 casos, projetos ou serviços em que cada item tem foto, uma categoria e 35 a 55 palavras de explicação.
- Quando existe foto boa de cada item. O componente é 70% imagem: sem foto ele não tem o que expandir.
- Quando o site ainda não tem (nem vai ter) uma página por caso. Se cada caso já tem página própria, o card deve virar link — veja a variação.
- Como respiro depois de uma dobra densa de texto: o trilho é a única coisa da página que rola na horizontal, e isso sozinho já muda o ritmo da leitura.
Quando não usar
- Quando o conteúdo passa de 60 palavras ou pede subtítulo, lista ou segunda foto. O overlay tem um slot de parágrafo, não um corpo de artigo. Passando disso, o card vira link para uma página de caso.
- Quando os itens precisam ser comparados lado a lado. O trilho mostra três por vez e o overlay mostra um; nenhum dos dois compara nada. Para comparar, use
conteudo/grid-entidades. - Quando os itens não têm foto, ou têm foto de qualidade desigual. Uma foto ruim no meio do trilho derruba as outras cinco.
- Como último bloco antes do rodapé. O overlay cobre a tela inteira; fechá-lo devolve a pessoa a um ponto da página onde só resta ir embora.
Padrão editorial
| Slot | Regra |
|---|---|
Olho (.eyebrow) |
1 a 2 palavras. O tipo de coleção: "Trabalhos", "Casos", "Portfólio". |
Título (.sec-title) |
1 linha, uma palavra em <em>. Nomeia o conjunto, não convida ao clique: "O trabalho fala por si". |
Apoio (.sec-lead) |
1 parágrafo, 20 a 35 palavras. Diz o critério de entrada da coleção — por que estes casos e não outros. |
data-cat |
1 a 2 palavras, a mesma taxonomia em todos os cards. Categorias soltas ("Branding", "Projeto especial", "Outros") denunciam que não existe critério. |
data-title |
4 a 8 palavras. Nomeia o que aconteceu, não o cliente: "A conversa que a família adiava" vale mais que o nome da empresa — e não expõe cliente nenhum. |
data-desc |
35 a 55 palavras, em um parágrafo só. Passado, sem jargão interno. Todos os cards com comprimento parecido: a coluna centraliza na vertical, e um texto muito mais curto que os vizinhos desequilibra a troca de um card para o outro. |
data-tags |
3 a 4 rótulos, 1 a 2 palavras cada, separados por vírgula. São frentes de trabalho, não adjetivos. |
alt da foto |
Obrigatório e descritivo. É o único conteúdo que chega a quem não vê a imagem — e ele reaparece no overlay. |
| CTA | Um só, o mesmo para todos os cards, no markup. Só use data-href por card quando cada caso tiver mesmo um destino diferente. |
Use de 4 a 10 cards. Com 3 o trilho não transborda em desktop, a barra de progresso some sozinha e o gesto de rolar lateralmente nunca acontece — a seção fica prometendo um movimento que não existe. Acima de 10, ninguém chega ao fim.
Especificação das imagens
| Proporção | retrato, 3:4 — a caixa do card é aspect-ratio:3/4 |
| Mínimo | 900×1200. No overlay a foto ocupa metade da tela em altura cheia |
| Recorte | object-fit:cover nos dois lugares. O assunto tem de sobreviver a corte lateral no card e a corte vertical no overlay |
| Peso | com loading="lazy", só as visíveis carregam de início; ainda assim, abaixo de 250 KB cada |
| Tom | fotos de contraste médio para escuro. O card recebe grayscale(.25) em repouso e volta ao normal no hover — foto já dessaturada não tem para onde ir |
data-img existe para apontar uma versão maior da mesma foto: o card pode carregar um recorte leve e o overlay a imagem cheia. Quando as duas são a mesma, pode omitir — o script cai na foto do card. O script pré-carrega a versão grande no primeiro hover ou foco do card, então ela chega antes da animação terminar.
Como usar
<link rel="stylesheet" href="componentes/overlay/card-expansivel-fullscreen/estilo.css">
<script src="componentes/overlay/card-expansivel-fullscreen/script.js"></script>
Cole markup.html, estilo.css e script.js. Depende do core (tokens, base, motion) e de mais nada.
Card novo é só mais um <li>. Todo o conteúdo do overlay mora nos data-* do card; não existe um segundo bloco de HTML por caso, e não há nada para manter em sincronia.
<li class="exp-item">
<button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
data-img="img/caso-1-grande.webp"
data-cat="Consultoria"
data-title="Título do caso"
data-desc="Parágrafo de 35 a 55 palavras."
data-tags="Uma,Duas,Três">
<img class="exp-card-img" src="img/caso-1.webp" alt="Descrição da foto" loading="lazy" decoding="async">
...
</button>
</li>
Ganchos
| Gancho | Papel |
|---|---|
[data-exp] |
a raiz. O script aceita vários trilhos na mesma página, cada um com o seu overlay |
[data-exp-track] |
o trilho que rola. É aqui que vai o data-rv, nunca no .exp |
[data-exp-card] |
o card. Um <button> de verdade, com aria-haspopup="dialog" |
[data-exp-overlay] |
o diálogo. role="dialog", aria-modal="true", hidden no repouso |
[data-exp-close], [data-exp-rail] |
botão de fechar e barra de progresso do trilho |
[data-exp-img|cat|title|desc|tags|cta] |
os destinos dentro do overlay. O script preenche |
.exp-overlay.is-shown |
conteúdo visível — posto depois que a caixa terminou de crescer |
.exp.is-scrollable |
o trilho transborda. Só aí a barra aparece |
.exp-overlay.is-measuring |
janela de medição, com a transição desligada. Estado interno do script |
--exp-tx/ty/sx/sy/r |
o canal do FLIP: o script escreve, o CSS monta transform e border-radius |
--exp-thumb, --exp-shift |
o canal da barra, em frações de 0 a 1 |
--exp-dur, --exp-fade |
duração da caixa e do conteúdo |
--exp-card-w, --exp-gap, --exp-z |
largura do card, vão do trilho e camada do overlay |
Atributos do card
| Atributo | Obrigatório | O quê |
|---|---|---|
data-cat |
sim | categoria; vira o olho do overlay |
data-title |
sim | título; vira o rótulo acessível do diálogo |
data-desc |
sim | o parágrafo |
data-tags |
não | lista separada por vírgula; vira .pill do core. Ausente, o bloco some |
data-img |
não | versão maior da foto. Ausente, usa a foto do card |
data-alt |
não | alt da versão maior. Ausente, usa o alt da foto do card |
data-href |
não | destino do CTA só para este card. Ausente, usa o href do markup |
Variações
| Variação | Como |
|---|---|
| Quantidade de cards | 4 a 10. Abaixo de 4 a barra some e o trilho não rola |
| Card sem overlay | tire o data-exp-card e troque o <button> por <a href>. O card vira link para a página do caso |
| Largura do card | --exp-card-w no .exp |
| Ritmo | --exp-dur (.52s) e --exp-fade (.34s) no .exp |
| Camada | --exp-z (500). Suba se o menu fixo do projeto passar disso |
Gotchas
-
O
data-rvvai no.exp-track, nunca no.exp. O reveal do core aplicafilter: blur(), efiltercria bloco de contenção para descendentesposition:fixed— inclusive comblur(0), que continua sendo um filtro. Subindo odata-rvum nível, o overlay para de cobrir a tela e passa a viver dentro do trilho, sem erro nenhum no console. Vale para qualquer ancestral: nada acima do overlay pode tertransform,filter,perspective,backdrop-filterouwill-changedesses. -
O overlay tem de continuar dentro da
<section>. É de lá que ele herda o escopo de tom (--bg,--fg,--fg-2). Custom property herda pela árvore do DOM, não pelo layout, entãoposition:fixednão atrapalha — mas mover o bloco para fora da seção, sim. -
A escala é não uniforme e distorce o que estiver dentro. É por isso que o conteúdo só entra depois que a caixa para: durante o crescimento, o overlay é uma superfície vazia, e não há o que distorcer. Se você fizer o conteúdo aparecer junto, ele entra esticado. O encadeamento por
transitionendnão é enfeite. -
A escala é medida contra o próprio overlay, nunca contra
documentElement.clientWidth. Com oscrollbar-gutter:stabledocore/base.css, o bloco de contenção de umposition:fixedé 15px mais estreito que oclientWidthque o documento reporta com o scroll travado. Usando oclientWidth, a caixa nasce ~4px fora do card — visível, e sem nenhum sintoma que aponte a causa. O script medeoverlay.offsetWidth/offsetHeight: o elemento que vai animar não tem como divergir de si mesmo. -
A classe
.is-measuringexiste por causa dessa medição. Para medir, o overlay precisa sair do[hidden]— e nesse instante ele já está em tela cheia. Sem desligar a transição na janela entre medir e posicionar, o efeito roda ao contrário: a caixa aparece cheia e encolhe até o card. São quatro recálculos de estilo em sequência noabrir(), cada um comentado no script; não reordene sem entender os quatro. -
O raio inicial é
--radius-media, não 16px. O script lê oborder-radiuscomputado do próprio card. Em tema flat (--radius-media:0) não há raio para animar e o crescimento sai reto — que é o certo, não um bug. Ocalc(--exp-r / --exp-sx)do CSS existe porque um raio dentro de uma caixa escalada em.22sairia com 3,5px na tela: dividir antes é o que faz o começo bater com o card. -
Clique no fundo fecha, mas "fundo" aqui é literal. O script compara o alvo por identidade com o overlay, a grade e a coluna de texto. Clicar no parágrafo, na foto, numa tag ou no CTA não fecha. Em tela larga o espaço vazio acima e abaixo do texto é o alvo real; em tela estreita quase não existe, e as saídas de verdade são o X e o
Esc. É deliberado: um "fecha se clicar fora do conteúdo" fecharia ao arrastar para selecionar texto. -
A barra de rolagem do sistema está escondida. Em fundo escuro ela corta a composição. Em troca, o
.exp-raildevolve a informação de quanto ainda falta — esconder sem repor seria esconder. Se o projeto preferir a barra nativa, apaguescrollbar-width:nonee o bloco::-webkit-scrollbar, e apague a barra junto: as duas ao mesmo tempo ficam redundantes. -
A trava de scroll se corrige sozinha. Ela mede a largura útil antes e depois de esconder o overflow e repõe a diferença como
padding-rightnobody. Com oscrollbar-gutter:stabledocore/base.cssa diferença dá zero e nada é acrescentado. Não troque por um15pxfixo: em máquina com barra sobreposta (macOS, telefone) isso empurra a página para o lado sem motivo. -
Trava e medição, nessa ordem. O retângulo do card é medido depois de travar o scroll, porque a trava pode mexer no layout. Invertendo, a caixa nasce alguns pixels fora do card e o efeito inteiro perde a graça.
-
Em
prefers-reduced-motionnão há FLIP. O script pula a medição e o overlay simplesmente aparece inteiro. As durações do CSS caem para.01msem vez denone, de propósito: assim otransitionendcontinua disparando e o encadeamento do script segue por um caminho só, em vez de ter dois. -
Escé escutado nodocument, não no overlay. Diferente domidia/galeria-pasta, aqui isso é o certo: um diálogo modal tem de fechar comEscesteja o foco onde estiver. Cada trilho registra um listener e ele só age quando aquele overlay está aberto. -
Foco preso, mas só nos focáveis que existem. A busca é refeita a cada
Tabporque o CTA muda dehrefe o bloco de tags pode estarhidden. Com o overlay fechado ohiddentira tudo da ordem de tabulação — não apague esse atributo do markup. -
A foto do overlay não tem
srcno HTML. Umsrc=""dispara um pedido para a própria página. Quem preenche é o script, sempre antes de tirar o[hidden]. -
--exp-gutterreproduz a borda da coluna fora do.wrap. O trilho sangra até a borda de propósito (contrato §4: quem sangra sai do.wrap), e a contamax(0px,(100% - --max)/2) + --padé o que alinha a primeira foto com o título. Trocando--maxou--padno tema, o alinhamento acompanha sozinho.
Histórico
| Projeto | Onde | Data | Diferenças |
|---|---|---|---|
| Gabriel Rocha Studio | gabriel-rocha-studio-2.html, #cases |
2026-08 | Origem. Site autoral do próprio estúdio, rodou uma vez. GSAP com timeline, <div> clicável, top/left/width/height animados, innerHTML nas tags, sem role="dialog", sem media query própria. |
Status experimental: rodou uma vez, num site só, e foi bastante reescrito na travessia. Espere ajuste no segundo uso.
O que mudou na travessia
| No original | Aqui | Por quê |
|---|---|---|
GSAP timeline com power3.inOut |
FLIP em transform, encadeado por transitionend |
O contrato só admite GSAP para timeline encadeada de verdade ou ScrollTrigger com scrub. Duas etapas em sequência resolvem-se com dois transitionend |
Animava top, left, width, height |
translate() + scale() com transform-origin:0 0 |
Aquelas quatro propriedades disparam layout a cada quadro; transform é composto na GPU. A expansão passa a ser barata mesmo em telefone |
borderRadius:16 cru |
--radius-media lido do card, dividido pela escala |
Cor, forma e raio vêm do tema. E sem a divisão o raio sairia esmagado junto com a caixa |
<div class="exp-card"> com listener de clique |
<button type="button"> com aria-haspopup="dialog" |
Foco, Enter e Espaço de graça; e o card entra na ordem de tabulação sem tabindex inventado |
Sem role, sem aria-modal, sem foco preso, sem devolução de foco |
role="dialog", aria-modal="true", aria-labelledby com id gerado, Tab preso e foco de volta ao card |
Solução do navegacao/drawer-fullscreen, com o foco preso que faltava lá — aqui é modal de verdade |
document.body.style.overflow='hidden' e depois '' |
Guarda o valor anterior e repõe a largura da barra de rolagem medindo antes/depois | O original apagava qualquer overflow que a página já tivesse e deixava a página saltar para a direita na abertura |
expTags.innerHTML = ...split(',').map('<span…>') |
createElement + textContent |
Montar HTML concatenando um atributo injeta marcação assim que alguém escrever < no CMS |
.exp-content{grid-template-columns:1fr 1fr} sem media query |
empilha abaixo de 900px; foto some em paisagem baixa | No original, telefone recebia duas colunas de 180px: foto ilegível de um lado, parágrafo de 45 palavras do outro |
| Fechar clicando fora comparava só com o overlay | compara com overlay, grade e coluna de texto | A grade cobre o overlay inteiro, então o alvo era sempre um filho: o clique-fora do original nunca fechava nada |
stroke="white" e stroke="#fff" nos SVG |
stroke="currentColor" |
Ícone tem de virar junto com o tema |
| Barra de rolagem escondida, sem nada no lugar | escondida + .exp-rail com progresso |
A informação de "ainda tem mais para o lado" tinha sumido junto com a barra |
.exp-card-overlay (gradiente) + .exp-card-info (texto) |
um bloco só, com gradiente, backdrop-filter e máscara |
O véu do contrato §6 pede máscara em gradiente; e duas caixas empilhadas para o mesmo efeito viravam duas camadas de composição por card |
Cores cruas (rgba(6,7,10,.88), #3a3a36, rgba(255,255,255,.1)) |
--dark-rgb, --rule-2, primitiva .glass |
O mesmo componente tem de rodar em qualquer tema |
.exp-tag com borda, raio e tracking próprios |
primitiva .pill do core |
Era a terceira cópia da mesma pílula na biblioteca (contrato §6) |
.cases-head com h2 e .eyebrow próprios |
.sec-head + h2.sec-title + .sec-lead do core |
Cabeçalho de dobra já é primitiva |
| Sem tratamento de movimento reduzido | FLIP desligado, durações a .01ms, hover sem scale |
Exigência do contrato §5 |
Fotos por caminho de imgs/ do site |
placeholders locais do showroom | Conteúdo de cliente não entra na biblioteca |
| Nomes de projeto inventados no demo | slots descritivos e genéricos | Demo da biblioteca não nomeia marca nem pessoa |