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

Galeria em pasta com leque de fotos

🟢 estavel complexidade alta prefixo fold- galeria imagem clique arrastar institucional pasta

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

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

markup.html · 62 linhas · 3,1 KB

<!-- ==========================================================================
     GALERIA EM PASTA — pilha de fotos que abre em leque
     Prefixo: fold-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. O leque de hover é CSS puro; o JS só abre, fecha e arrasta.

     Cada foto declara --i (posição em relação ao centro, negativa à esquerda)
     e --a (o mesmo em módulo, para a escala). São os dois números que o CSS
     usa para montar pilha, leque e linha aberta — mude a quantidade de fotos
     e só o --i/--a precisa acompanhar.

     Ímpar fica melhor: com 5, o --i vai de -2 a 2 e o centro é o 0.
     ========================================================================== -->

<section class="sec-dark sec-pad" id="galeria">
  <div class="wrap">
    <span class="eyebrow" data-rv>Nossos escritórios</span>
    <h2 class="sec-title ttl">Onde o trabalho <em>acontece</em></h2>
    <p class="sec-lead" data-rv data-d="1">Uma frase de apoio situando o que a pasta guarda: o lugar, o time, o ano. Duas linhas no máximo — a atenção aqui é da imagem, não do texto.</p>

    <div class="fold" data-fold data-rv data-d="2">
      <div class="fold-stage">

        <div class="fold-body" aria-hidden="true">
          <span class="fold-body-tab"></span>
          <span class="fold-body-back"></span>
        </div>

        <ul class="fold-items" id="fold-items">
          <li class="fold-item" style="--i:-2;--a:2">
            <img class="fold-img" src="../../../showroom/demo/ph-6.webp" alt="Globo terrestre antigo sobre mesa de biblioteca">
            <span class="fold-cap">Biblioteca</span>
          </li>
          <li class="fold-item" style="--i:-1;--a:1">
            <img class="fold-img" src="../../../showroom/demo/ph-7.webp" alt="Reunião de quatro pessoas em sala de vidro">
            <span class="fold-cap">Sala de reunião</span>
          </li>
          <li class="fold-item" style="--i:0;--a:0">
            <img class="fold-img" src="../../../showroom/demo/ph-4.webp" alt="Três pessoas conversando em escritório com vista para a cidade">
            <span class="fold-cap">Escritório, andar 14</span>
          </li>
          <li class="fold-item" style="--i:1;--a:1">
            <img class="fold-img" src="../../../showroom/demo/ph-8.webp" alt="Mesa de trabalho com xícara de café, caderno e óculos">
            <span class="fold-cap">Mesa de trabalho</span>
          </li>
          <li class="fold-item" style="--i:2;--a:2">
            <img class="fold-img" src="../../../showroom/demo/ph-3.webp" alt="Duas pessoas conversando junto a janela com vista da cidade">
            <span class="fold-cap">Vista norte</span>
          </li>
        </ul>

        <button class="fold-front" type="button" data-fold-open aria-expanded="false" aria-controls="fold-items">
          <span class="fold-label">Escritórios.2026</span>
        </button>

      </div>

      <p class="fold-hint" data-fold-hint aria-hidden="true">Arraste uma foto para baixo para fechar</p>
      <button class="fold-close" type="button" data-fold-close tabindex="-1">Fechar a pasta</button>
    </div>
  </div>
</section>

estilo.css · 159 linhas · 9,3 KB

/* ==========================================================================
   GALERIA EM PASTA — prefixo fold-
   Pilha de fotos dentro de uma pasta. Clique abre em linha, arrastar uma
   foto para baixo fecha.

   HOVER NÃO ABRE O LEQUE, desde 2026-09-29 (CLAUDE.md §6). Até então as
   fotos saltavam da pasta em arco sob o ponteiro, e cada foto aberta subia
   e crescia ao passar o mouse. Saiu tudo: imagem que se mexe no hover é o
   que quebra a página quando o componente é colado noutro lugar. Sob o
   ponteiro a pasta só acende a borda e o rótulo. Cada foto lê --i (posição
   em relação ao centro) e --a (o mesmo em módulo) do próprio style inline;
   sem JS a pilha continua legível e parada.
   ========================================================================== */

/* --dur do core é .9s: certo para entrada de seção, lento para o leque
   responder ao ponteiro. A duração de interação é local e fica aqui, à
   mão de quem for ajustar. A curva continua sendo a do projeto. */
.fold{--fold-dur:.42s;--fold-dur-lento:.6s;--fold-spread:132px;
  margin-top:clamp(34px,5vh,56px);display:flex;flex-direction:column;align-items:center}

/* O palco reserva a altura toda: as fotos são absolutas e não empurram nada.
   A perspectiva é do palco, senão a aba tomba achatada. */
.fold-stage{position:relative;width:100%;max-width:420px;height:460px;
  display:flex;justify-content:center;perspective:1400px}

/* ---------------------------------------------------------------- a pasta */
/* Duas peças: a abinha de cima (o "dente" da pasta suspensa) e o corpo.
   Ficam atrás de tudo e somem quando a pasta abre. */
.fold-body{position:absolute;bottom:26px;width:min(320px,84%);height:224px;
  transition:opacity var(--fold-dur) var(--ease),transform var(--fold-dur) var(--ease)}
.fold-body-tab{position:absolute;top:0;left:0;width:128px;height:38px;
  background:linear-gradient(to top,var(--dark-2),color-mix(in srgb,var(--fg) 12%,var(--dark-2)));
  border:1px solid var(--rule);border-bottom:0;
  border-radius:var(--radius-media) var(--radius-media) 0 0}
.fold-body-back{position:absolute;top:32px;left:0;right:0;bottom:0;
  background:linear-gradient(to bottom,var(--dark-2),var(--dark));
  border:1px solid var(--rule);border-radius:0 var(--radius-media) var(--radius-media) var(--radius-media);
  box-shadow:inset 0 0 40px rgba(var(--dark-rgb),.8)}

/* ---------------------------------------------------------------- aba da frente */
/* É um <button> de verdade: foco, Enter e Espaço vêm de graça, e o leitor de
   tela anuncia o estado por aria-expanded. O original era uma <div> clicável. */
.fold-front{position:absolute;bottom:0;width:min(340px,90%);height:176px;z-index:20;
  background:linear-gradient(to bottom,color-mix(in srgb,var(--fg) 12%,var(--dark-2)),var(--dark));
  border:1px solid var(--rule-2);border-radius:var(--radius-media);
  box-shadow:inset 0 2px 10px color-mix(in srgb,var(--fg) 8%,transparent),0 -20px 40px rgba(var(--dark-rgb),.8);
  transform-origin:bottom;cursor:pointer;overflow:hidden;
  display:flex;align-items:flex-end;justify-content:center;padding-bottom:30px;
  transition:transform var(--fold-dur) var(--ease),opacity var(--fold-dur) var(--ease)}
/* o fio de luz na quina de cima, que dá volume à aba */
.fold-front::before{content:"";position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(to right,transparent,color-mix(in srgb,var(--fg) 40%,transparent),transparent)}
.fold-front:focus-visible{outline:2px solid var(--accent);outline-offset:3px}

.fold-label{font-family:var(--display);font-weight:700;font-size:13.5px;
  letter-spacing:.02em;color:var(--fg);background:var(--dark);
  border:1px solid rgba(var(--dark-rgb),.8);border-radius:var(--radius-media);
  padding:10px 20px;box-shadow:inset 0 1px 6px rgba(var(--dark-rgb),.9)}

/* hover: a pasta avisa que é clicável sem se mexer — borda e rótulo na cor
   de marca. A aba tombava e as fotos saltavam em leque; ver o cabeçalho. */
.fold-stage:hover .fold-front,
.fold-front:focus-visible{border-color:color-mix(in srgb,var(--accent) 60%,var(--rule-2))}
.fold-stage:hover .fold-label,
.fold-front:focus-visible .fold-label{color:var(--accent)}

/* aberta: pasta e aba saem de cena */
.fold.is-open .fold-front{opacity:0;transform:none;pointer-events:none}
.fold.is-open .fold-body{opacity:0;transform:scale(.9);transition-duration:var(--fold-dur-lento)}

/* ---------------------------------------------------------------- as fotos */
.fold-items{position:absolute;bottom:40px;z-index:10;
  display:flex;justify-content:center;list-style:none;margin:0;padding:0}

/* --dx/--dy/--dr são o canal do arraste: o script só escreve estes três
   valores e o CSS recompõe o transform inteiro. Assim o JS não precisa
   conhecer a fórmula da posição, que continua morando aqui. */
.fold-item{--dx:0px;--dy:0px;--dr:0deg;
  position:absolute;bottom:0;width:224px;height:288px;
  border:1px solid var(--rule-2);border-radius:var(--radius-media);overflow:hidden;
  box-shadow:0 20px 40px rgba(var(--dark-rgb),.5);transform-origin:bottom;
  pointer-events:none;
  /* z-index sobe com a posição para a foto da direita ficar na frente */
  z-index:calc(10 + var(--i));
  transition:transform var(--fold-dur) var(--ease),box-shadow var(--fold-dur) var(--ease);
  /* PILHA — estado de repouso: quase alinhadas, leque mínimo */
  transform:translateX(calc(var(--i) * 3px)) translateY(calc(var(--i) * -5px))
            rotate(calc(var(--i) * 3deg)) scale(calc(1 - var(--a) * .03))}

.fold-img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}

/* ABERTA — linha reta, todas do mesmo tamanho, sem rotação */
.fold.is-open .fold-item{
  pointer-events:auto;cursor:grab;z-index:50;
  transform:translateX(calc(var(--i) * var(--fold-spread) + var(--dx)))
            translateY(calc(-130px + var(--dy))) rotate(var(--dr)) scale(1.05)}
.fold.is-open .fold-item:focus-visible{outline:2px solid var(--accent);outline-offset:3px;z-index:100}

/* durante o arraste o JS assume o transform e a transição sai da frente */
.fold-item.is-dragging{cursor:grabbing;transition:none;z-index:150;
  box-shadow:0 30px 60px rgba(var(--dark-rgb),.7)}
/* soltou sem fechar: volta para o lugar com transição de novo */
.fold-item.is-returning{transition:transform var(--fold-dur) var(--ease)}

/* ---------------------------------------------------------------- legenda */
/* Dentro da foto, ancorada embaixo, com véu em gradiente mascarado — véu em
   caixa reta lê chapado (contrato §6). Só aparece com a pasta aberta: na
   pilha as fotos estão sobrepostas e a legenda viraria ruído. */
.fold-cap{position:absolute;left:0;right:0;bottom:0;padding:34px 15px 13px;
  font-size:11.5px;letter-spacing:.05em;text-transform:uppercase;
  font-family:var(--display);font-weight:700;color:var(--fg);
  background:linear-gradient(to top,rgba(var(--dark-rgb),.92),transparent);
  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%);
  opacity:0;transition:opacity var(--fold-dur) var(--ease)}
.fold.is-open .fold-cap{opacity:1}

/* ---------------------------------------------------------------- apoio */
.fold-hint{margin-top:20px;font-size:11.5px;letter-spacing:.11em;text-transform:uppercase;
  font-family:var(--display);font-weight:700;color:var(--fg-muted);
  border:1px solid var(--rule);border-radius:var(--radius-pill);padding:10px 22px;
  opacity:0;transform:translateY(14px);pointer-events:none;
  transition:opacity var(--fold-dur) var(--ease),transform var(--fold-dur) var(--ease)}
.fold.is-open .fold-hint{opacity:1;transform:none}

/* Saída por teclado. O arraste é a graça do componente, mas não pode ser o
   único jeito de fechar: fica fora do alcance de quem navega por teclado ou
   usa leitor de tela. Some do fluxo enquanto a pasta está fechada. */
.fold-close{margin-top:14px;font-family:var(--display);font-weight:700;font-size:11px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--fg-2);background:none;
  border:0;padding:8px 4px;cursor:pointer;transition:color .25s;
  position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
.fold-close:hover{color:var(--accent)}
.fold.is-open .fold-close{position:static;width:auto;height:auto;clip-path:none}
.fold-close:focus-visible{outline:2px solid var(--accent);outline-offset:3px}

/* ---------------------------------------------------------------- movimento reduzido */
/* Sem deslocamento nem rotação: a pasta abre trocando opacidade e as fotos
   viram uma grade quieta. O componente continua inteiro, só não se mexe. */
@media(prefers-reduced-motion:reduce){
  .fold-item,.fold-front,.fold-body,.fold-cap,.fold-hint{transition-duration:.01ms}
}

/* ---------------------------------------------------------------- mobile */
/* Aberta, cinco fotos de 224px não cabem em tela estreita. O espalhamento
   encolhe e as fotos passam a se sobrepor de propósito — vira um leque
   apertado em vez de uma linha, que é o que a largura permite. */
@media(max-width:860px){
  .fold{--fold-spread:74px}
  .fold-item{width:180px;height:232px}
  .fold-stage{height:420px}
}
@media(max-width:520px){
  .fold{--fold-spread:46px}
  .fold-item{width:150px;height:196px}
  .fold-stage{height:380px}
  .fold-hint{font-size:10.5px;padding:9px 16px;text-align:center}
}

script.js · 140 linhas · 4,6 KB

/* ==========================================================================
   GALERIA EM PASTA — abrir, fechar e arrastar.

   O leque de hover é CSS e não passa por aqui. Este script só cuida do que
   o CSS não alcança: o estado aberto, as saídas e o arraste.

   O arraste escreve --dx/--dy/--dr na foto; quem recompõe o transform é o
   estilo.css. Assim mudar a posição aberta não exige mexer no script.
   ========================================================================== */
(function () {
  var raizes = document.querySelectorAll('[data-fold]');
  if (!raizes.length) return;

  var LIMITE = 100;   /* px para baixo que fecham a pasta */
  var GIRO   = 0.04;  /* graus por px de arraste lateral */

  /* A flag vem do core, que ja' resolve o ?motion=on. Ler matchMedia aqui
     duplicaria a regra e perderia esse escape. */
  function reduzido() {
    return !!(window.LIB && window.LIB.reduce);
  }

  function iniciar(raiz) {
    var abrir  = raiz.querySelector('[data-fold-open]');
    var fechar = raiz.querySelector('[data-fold-close]');
    var itens  = raiz.querySelectorAll('.fold-item');
    if (!abrir || !itens.length) return;

    function estado(aberta) {
      raiz.classList.toggle('is-open', aberta);
      abrir.setAttribute('aria-expanded', aberta ? 'true' : 'false');
      for (var i = 0; i < itens.length; i++) {
        itens[i].setAttribute('tabindex', aberta ? '0' : '-1');
        zerar(itens[i]);
      }
      if (fechar) fechar.tabIndex = aberta ? 0 : -1;
    }

    function zerar(item) {
      item.style.removeProperty('--dx');
      item.style.removeProperty('--dy');
      item.style.removeProperty('--dr');
      item.classList.remove('is-dragging');
    }

    abrir.addEventListener('click', function () {
      estado(true);
      itens[Math.floor(itens.length / 2)].focus();
    });

    if (fechar) {
      fechar.addEventListener('click', function () {
        estado(false);
        abrir.focus();
      });
    }

    /* Esc fecha, mas só com o foco dentro do componente: a tecla é disputada
       por drawer, modal e busca, e quem está longe daqui não espera isto. */
    raiz.addEventListener('keydown', function (e) {
      if (e.key === 'Escape' && raiz.classList.contains('is-open')) {
        estado(false);
        abrir.focus();
      }
    });

    /* clique fora fecha */
    document.addEventListener('pointerdown', function (e) {
      if (raiz.classList.contains('is-open') && !raiz.contains(e.target)) estado(false);
    }, { passive: true });

    /* ------------------------------------------------------------ arraste */
    for (var i = 0; i < itens.length; i++) {
      ligarArraste(itens[i]);
    }

    function ligarArraste(item) {
      var x0 = 0, y0 = 0, dx = 0, dy = 0, arrastando = false, pedido = 0;

      function pintar() {
        pedido = 0;
        item.style.setProperty('--dx', dx + 'px');
        item.style.setProperty('--dy', dy + 'px');
        item.style.setProperty('--dr', (dx * GIRO) + 'deg');
      }

      item.addEventListener('pointerdown', function (e) {
        if (!raiz.classList.contains('is-open') || reduzido()) return;
        arrastando = true;
        x0 = e.clientX; y0 = e.clientY; dx = 0; dy = 0;
        item.classList.remove('is-returning');
        item.classList.add('is-dragging');
        item.setPointerCapture(e.pointerId);
      });

      item.addEventListener('pointermove', function (e) {
        if (!arrastando) return;
        dx = e.clientX - x0;
        dy = e.clientY - y0;
        /* preso ao ponteiro: pinta no frame, nunca no listener */
        if (!pedido) pedido = requestAnimationFrame(pintar);
      });

      function soltar() {
        if (!arrastando) return;
        arrastando = false;
        if (pedido) { cancelAnimationFrame(pedido); pedido = 0; }
        item.classList.remove('is-dragging');

        if (dy > LIMITE) {
          estado(false);
          abrir.focus();
          return;
        }
        /* não passou do limite: volta sozinha para o lugar */
        item.classList.add('is-returning');
        zerar(item);
        setTimeout(function () { item.classList.remove('is-returning'); }, 500);
      }

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

      /* teclado: seta para baixo faz o papel do arraste */
      item.addEventListener('keydown', function (e) {
        if (e.key === 'ArrowDown' || e.key === 'Escape') {
          e.preventDefault();
          estado(false);
          abrir.focus();
        }
      });
    }

    estado(false);
  }

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

Uma pasta fechada com uma pilha de fotos saindo por cima. O clique estende as fotos em linha e tira a pasta de cena; arrastar qualquer foto para baixo fecha tudo de volta. Sob o ponteiro a pasta só acende a borda e o rótulo — ela não se mexe (ver Gotchas).

É o primeiro componente da categoria mídia, e o primeiro de origem externa da biblioteca.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho (.eyebrow) 1 a 2 palavras. O tipo de coleção: "Bastidores", "Arquivo", "2026".
Título (.sec-title) 1 linha, uma palavra em <em>. Nomeia o lugar ou o período, não a ação: "Onde o trabalho acontece".
Apoio (.sec-lead) 1 parágrafo, 20 a 35 palavras. Situa o que a pasta guarda. A atenção é da imagem — não escreva três linhas aqui.
Rótulo da pasta (.fold-label) 2 a 4 palavras, formato de nome de arquivo: Bastidores.2026. É o que faz a metáfora fechar. Evite frase.
Legenda (.fold-cap) 2 a 4 palavras por foto, minúsculas viram caixa alta no CSS. Diz onde, não o que se vê: "Corredor norte", não "Um corredor bonito".
alt de cada foto Obrigatório e descritivo. É o único conteúdo que chega a quem não vê a imagem.

Quantidade: use ímpar. O --i vai de -n a n com o centro em 0. Com número par não existe centro, e o leque fica assimétrico sem parecer intencional. Cinco é o padrão; três funciona; sete começa a apertar no desktop.


Especificação das imagens

Proporção retrato, ~3:4 (a caixa é 224×288)
Mínimo 900×1200, para aguentar o scale(1.05) da abertura
Recorte object-fit:cover — o assunto tem de sobreviver a corte nas laterais
Peso as 5 carregam de uma vez, sem lazy: mantenha cada uma abaixo de 200 KB
Tom fotos escuras ou de contraste médio. Foto clara demais briga com a pasta e some a legenda

Não há loading="lazy" de propósito: as fotos estão na pilha desde o início, e uma foto chegando depois do clique apareceria em branco no meio da linha.


Como usar

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

Cada <li class="fold-item"> declara dois números no style:

<li class="fold-item" style="--i:-2;--a:2">

--i é a posição em relação ao centro, negativa à esquerda. --a é o mesmo em módulo, e serve só para a escala da pilha. Trocou a quantidade de fotos, refaça a sequência: com 5 é -2 -1 0 1 2, com 3 é -1 0 1.

Ganchos

Gancho O quê
[data-fold] a raiz. O script aceita várias na mesma página
.fold.is-open estado aberto, posto pelo script
[data-fold-open] a aba da frente, um <button> de verdade, com aria-expanded
[data-fold-close] saída por teclado; fica fora do fluxo enquanto fechada
--fold-spread espalhamento da linha aberta, no .fold
--dx / --dy / --dr canal do arraste: o script escreve, o CSS recompõe o transform

Variações

Variação Como
Quantidade de fotos refaça --i/--a. Prefira ímpar
Sem legenda apague .fold-cap
Espalhamento --fold-spread no .fold: 132px, 74px e 46px nos breakpoints
Ritmo --fold-dur (.42s) e --fold-dur-lento (.6s) no .fold

Gotchas

Histórico

Origem Onde Quando O quê
21st.dev interactive-folder-gallery.tsx, por alexperezcedeno 2026-08 Origem do conceito. React + Tailwind + framer-motion, sob licença MIT do registro do 21st.dev.

Reimplementado do zero, não adaptado: o original é incompatível com o contrato desta biblioteca em cinco pontos de uma vez (React, TypeScript, Tailwind, shadcn e framer-motion). O que veio de lá é a ideia — pasta, leque, arraste para fechar. O código é outro.

O que mudou na travessia:

No original Aqui Por quê
framer-motion com spring transition CSS com a curva --ease do projeto O contrato não aceita lib. A curva não é spring de verdade: o retorno não tem oscilação
Posições calculadas em JS por índice --i/--a no markup, fórmula no CSS A pilha e a linha saem da mesma fórmula, sem o JS conhecer posição
<div> clicável <button> com aria-expanded Foco, Enter e Espaço de graça; o estado é anunciado
Fechava só por arraste Arraste, Esc, seta para baixo, botão e clique fora Arraste é gesto de ponteiro; sozinho, exclui teclado
Sem tratamento de movimento Arraste desligado sob reduce Exigência do contrato
Leque em arco sob o ponteiro Saiu em 2026-09-29; o hover só acende borda e rótulo Hover não mexe na imagem (CLAUDE.md §6)
5 fotos do Unsplash por URL placeholders locais Dependência externa é proibida, e a CSP do site bloquearia
Cores cruas (#1e1e1e, #2a2a2a) --dark, --dark-2, --rule, --rule-2 O mesmo componente tem de rodar em qualquer tema

Calibragem no primeiro uso. O leque é dimensionado por --fold-spread, e a quantidade de fotos muda o resultado: número ímpar fica melhor, porque --i vai de -n a n com o centro em zero.