Pular para o conteúdo
Biblioteca GAL-01

Galeria bento arrastável

Grade de fotos em faixa horizontal arrastável, com entrada em cascata e modal que amplia a foto ao toque.

usa o GAL-01
abrir sozinho ↗
Código
GAL-01 · alias galeria-bento
Função
Galeria e mídia
Fonte
21st.dev · bento-gallery.tsx
Recebido
15/09/2026
Status
adotado
Licença
não verificada

O código

galeria.css + galeria.js + lightbox.css + lightbox.js + markup.html 347 linhas · 5 arquivo(s) numa caixa só
/* ===== galeria.css ================================================================= */

/* GAL-01 · Galeria bento — reescrita do original do 21st.dev sem React nem Framer Motion.
   Faixa horizontal de colunas: foto vertical ocupa a coluna inteira, duas horizontais empilham, e
   cada card fica na proporção da própria foto. A faixa anda sozinha pelo transform (galeria.js).

   Diferença do original: ele usa spans de grade que só valem a partir de `md`, o que deixa todos os
   cards iguais no celular. Em colunas, a composição bento vale em qualquer largura. */
.c-galeria {
  --c-gal-altura: 380px;
  --c-gal-gap: 10px;
  --c-gal-gutter: 24px;
  --c-gal-fundo: #141414;
  --c-gal-raio: 16px;
  --c-gal-tinta-2: #a8a8a8;

  position: relative;
  overflow: hidden;
  touch-action: pan-y;        /* o dedo na vertical rola a página; na horizontal, arrasta a faixa */
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
}
.c-galeria[data-arrastando="true"] { cursor: grabbing; }
.c-galeria__trilho {
  display: flex;
  gap: var(--c-gal-gap);
  height: var(--c-gal-altura);
  padding-left: var(--c-gal-gutter);
  will-change: transform;
}
.c-galeria__coluna { flex: none; display: flex; flex-direction: column; gap: var(--c-gal-gap); height: 100%; }
.c-galeria__card {
  position: relative;
  flex: 1 1 0;
  min-height: 0;
  margin: 0;
  overflow: hidden;
  border-radius: var(--c-gal-raio);
  background: var(--c-gal-fundo);
  cursor: zoom-in;
}
/* pointer-events: none na foto para o arraste pegar o card, não a imagem */
.c-galeria__foto { width: 100%; height: 100%; object-fit: cover; pointer-events: none; -webkit-user-drag: none; }
.c-galeria__legenda { margin-top: 10px; padding: 0 var(--c-gal-gutter); font-size: .85rem; color: var(--c-gal-tinta-2); }


/* ===== galeria.js ================================================================== */

/* GAL-01 · Galeria bento — reescrita do original do 21st.dev sem React nem Framer Motion.
   A faixa desliza devagar e sem fim; para ao toque ou arraste e retoma 1 s depois; arrastar move a
   faixa; tocar numa foto abre a ampliada (lightbox.js, opcional).

   O loop duplica as colunas uma vez (cópias com aria-hidden): passou a largura de um conjunto,
   volta ao zero. O original não anda sozinho — sem arraste, a faixa fica parada. */
(function () {
  'use strict';

  var VELOCIDADE = 18;   // px por segundo
  var RETOMA = 1000;     // ms depois de soltar
  var LIMIAR = 8;        // px de movimento a partir do qual deixa de ser toque

  function reduzirMovimento() {
    return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
  }

  function preparar(galeria) {
    var trilho = galeria.querySelector('.c-galeria__trilho');
    var colunas = trilho ? Array.prototype.slice.call(trilho.querySelectorAll('.c-galeria__coluna')) : [];
    if (!colunas.length) { return; }

    colunas.forEach(function (c) {
      var copia = c.cloneNode(true);
      copia.classList.add('c-galeria__coluna--clone');
      copia.setAttribute('aria-hidden', 'true');
      Array.prototype.forEach.call(copia.querySelectorAll('[data-ampliar]'), function (el) { el.setAttribute('tabindex', '-1'); });
      trilho.appendChild(copia);
    });

    var deslocamento = 0, largura = 0, visivel = false, travado = false, pausadoAte = 0;
    var gesto = null, rodando = false, ultimo = 0, timerRetoma = null;

    function medir() {
      var gap = parseFloat(getComputedStyle(trilho).columnGap) || 0;
      largura = colunas.reduce(function (soma, c) { return soma + c.getBoundingClientRect().width + gap; }, 0);
    }
    function aplicar() {
      if (largura > 0) { deslocamento = ((deslocamento % largura) + largura) % largura; }
      trilho.style.transform = 'translate3d(' + (-deslocamento).toFixed(2) + 'px, 0, 0)';
    }
    function podeAndar() {
      return visivel && !travado && !gesto && !document.hidden && !reduzirMovimento() && Date.now() >= pausadoAte;
    }
    function quadro(t) {
      if (!podeAndar()) { rodando = false; ultimo = 0; return; }
      if (ultimo) { deslocamento += VELOCIDADE * Math.min(64, t - ultimo) / 1000; aplicar(); }
      ultimo = t;
      window.requestAnimationFrame(quadro);
    }
    function acordar() {
      if (!rodando && podeAndar()) { rodando = true; ultimo = 0; window.requestAnimationFrame(quadro); }
    }
    function pausar(ms) {
      pausadoAte = Date.now() + ms;
      clearTimeout(timerRetoma);
      timerRetoma = setTimeout(acordar, ms + 20);
    }

    galeria.addEventListener('pointerdown', function (e) {
      if (e.button !== 0) { return; }
      gesto = { x: e.clientX, y: e.clientY, inicio: deslocamento, moveu: false, id: e.pointerId };
    });
    galeria.addEventListener('pointermove', function (e) {
      if (!gesto || e.pointerId !== gesto.id) { return; }
      var dx = e.clientX - gesto.x, dy = e.clientY - gesto.y;
      if (!gesto.moveu) {
        if (Math.abs(dx) < LIMIAR && Math.abs(dy) < LIMIAR) { return; }
        if (Math.abs(dy) > Math.abs(dx)) { gesto = null; pausar(RETOMA); return; }   // é a rolagem da página
        gesto.moveu = true;
        galeria.setAttribute('data-arrastando', 'true');
        try { galeria.setPointerCapture(e.pointerId); } catch (erro) { /* sem captura, o arraste segue */ }
      }
      deslocamento = gesto.inicio - dx;
      aplicar();
    });
    function soltar() {
      if (!gesto) { return; }
      var moveu = gesto.moveu;
      gesto = null;
      pausar(RETOMA);
      if (moveu) { setTimeout(function () { galeria.removeAttribute('data-arrastando'); }, 0); }
    }
    galeria.addEventListener('pointerup', soltar);
    galeria.addEventListener('pointercancel', soltar);
    /* Só a captura da PRÓPRIA galeria encerra o gesto. No toque, o navegador prende o dedo à foto
       tocada; quando a galeria pega a captura, a foto solta um lostpointercapture que sobe até aqui
       — tratado como fim, ele travava o arraste com o dedo no primeiro movimento (medido no iPhone). */
    galeria.addEventListener('lostpointercapture', function (e) { if (e.target === galeria) { soltar(); } });

    document.addEventListener('c:lightbox', function (e) {
      travado = !!(e.detail && e.detail.aberto);
      if (!travado) { pausar(RETOMA); } else { acordar(); }
    });
    document.addEventListener('visibilitychange', acordar);
    window.addEventListener('resize', function () { medir(); aplicar(); });
    window.addEventListener('load', function () { medir(); aplicar(); });
    if (window.matchMedia) {
      var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
      if (mq.addEventListener) { mq.addEventListener('change', acordar); }
    }
    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (entradas) {
        visivel = entradas[0].isIntersecting;
        if (visivel) { medir(); acordar(); }
      }, { threshold: 0 }).observe(galeria);
    } else {
      visivel = true;
    }

    galeria.__cGaleria = { pausar: pausar, acordar: acordar };
    medir();
    aplicar();
  }

  function iniciar() { Array.prototype.forEach.call(document.querySelectorAll('.c-galeria'), preparar); }

  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();


/* ===== lightbox.css ================================================================ */

/* GAL-01 · Foto ampliada — o modal que veio dentro do prompt da galeria bento, reescrito sem
   Framer Motion e desacoplado: serve a qualquer [data-ampliar] da página, não só à galeria.
   Fecha no botão do canto superior direito, tocando fora ou com Esc. */
.c-lightbox {
  --c-lb-veu: rgba(0, 0, 0, .88);
  --c-lb-tinta: #ebebeb;
  --c-lb-raio: 16px;

  position: fixed;
  z-index: 100;
  inset: 0;
  display: grid;
  place-items: center;
  padding: calc(64px + env(safe-area-inset-top, 0px)) 16px calc(24px + env(safe-area-inset-bottom, 0px));
  background: var(--c-lb-veu);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity .2s ease;
  touch-action: none;
  overscroll-behavior: contain;
}
.c-lightbox[hidden] { display: none; }
.c-lightbox.is-aberto { opacity: 1; }
.c-lightbox__palco { display: grid; place-items: center; width: 100%; height: 100%; min-height: 0; }
.c-lightbox__item {
  max-width: 100%;
  border-radius: var(--c-lb-raio);
  transform: scale(.96);
  transition: transform .25s cubic-bezier(.16, 1, .3, 1);
}
.c-lightbox.is-aberto .c-lightbox__item { transform: none; }
/* svh, não vh: no celular a barra do navegador entra na conta de vh e a foto estoura a tela */
img.c-lightbox__item { width: auto; height: auto; max-height: calc(100svh - 110px); object-fit: contain; }
.c-lightbox__fechar {
  position: absolute;
  top: calc(12px + env(safe-area-inset-top, 0px));
  right: 12px;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(235, 235, 235, .10);
  color: var(--c-lb-tinta);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.c-lightbox__fechar svg { width: 22px; height: 22px; }
html.c-sem-rolagem, html.c-sem-rolagem body { overflow: hidden; }


/* ===== lightbox.js ================================================================= */

/* GAL-01 · Foto ampliada. Toque em qualquer [data-ampliar] abre a foto inteira (data-ampliada, ou a
   própria <img>). Fecha no botão, tocando fora ou com Esc, e trava a rolagem enquanto aberta.
   Avisa quem precisa parar de andar pelo evento "c:lightbox" no document. */
(function () {
  'use strict';

  var caixa, palco, fechar, voltarPara = null, timerFechar = null;

  function avisar(aberto) {
    var ev;
    try {
      ev = new CustomEvent('c:lightbox', { detail: { aberto: aberto } });
    } catch (e) {
      ev = document.createEvent('CustomEvent');
      ev.initCustomEvent('c:lightbox', false, false, { aberto: aberto });
    }
    document.dispatchEvent(ev);
  }

  function conteudoDe(alvo) {
    var foto = alvo.tagName === 'IMG' ? alvo : alvo.querySelector('img');
    var img = document.createElement('img');
    img.className = 'c-lightbox__item';
    img.src = alvo.getAttribute('data-ampliada') || (foto ? (foto.currentSrc || foto.src) : '');
    img.alt = foto ? foto.alt : '';
    return img;
  }

  function abrir(alvo) {
    clearTimeout(timerFechar);
    palco.innerHTML = '';
    palco.appendChild(conteudoDe(alvo));
    voltarPara = document.activeElement;
    caixa.hidden = false;
    document.documentElement.classList.add('c-sem-rolagem');
    void caixa.offsetWidth;                 // força o quadro antes da transição de opacidade
    caixa.classList.add('is-aberto');
    fechar.focus({ preventScroll: true });
    avisar(true);
  }

  function fecharCaixa() {
    if (caixa.hidden || !caixa.classList.contains('is-aberto')) { return; }
    caixa.classList.remove('is-aberto');
    document.documentElement.classList.remove('c-sem-rolagem');
    timerFechar = setTimeout(function () { caixa.hidden = true; palco.innerHTML = ''; }, 220);
    if (voltarPara && voltarPara.focus) { voltarPara.focus({ preventScroll: true }); }
    avisar(false);
  }

  function iniciar() {
    caixa = document.getElementById('c-lightbox');
    if (!caixa) { return; }
    palco = caixa.querySelector('.c-lightbox__palco');
    fechar = caixa.querySelector('.c-lightbox__fechar');

    Array.prototype.forEach.call(document.querySelectorAll('[data-ampliar]'), function (el) {
      if (!el.hasAttribute('tabindex')) { el.setAttribute('tabindex', '0'); }
    });

    document.addEventListener('click', function (e) {
      var alvo = e.target.closest ? e.target.closest('[data-ampliar]') : null;
      if (!alvo || alvo.closest('#c-lightbox, [data-arrastando="true"]')) { return; }
      e.preventDefault();
      abrir(alvo);
    });
    /* na CAPTURA, e com a ampliada aberta o teclado é só dela: a tecla não segue para a página atrás
       — senão as setas de um carrossel, que ele ouve no document, passariam os itens por trás. */
    document.addEventListener('keydown', function (e) {
      if (!caixa.hidden && caixa.classList.contains('is-aberto')) {
        e.stopPropagation();
        if (e.key === 'Escape') { e.preventDefault(); fecharCaixa(); }
        if (e.key === 'Tab') { e.preventDefault(); fechar.focus(); }
        return;
      }
      var alvo = e.target.closest ? e.target.closest('[data-ampliar]') : null;
      if (alvo && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); abrir(alvo); }
    }, true);
    fechar.addEventListener('click', function (e) { e.stopPropagation(); fecharCaixa(); });
    caixa.addEventListener('click', function (e) {
      if (!e.target.closest('.c-lightbox__item')) { fecharCaixa(); }
    });
  }

  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();


/* ===== markup.html ================================================================= */

<!-- GAL-01 · Galeria bento. Cada .c-galeria__coluna monta a composição: uma foto vertical ocupa a
     coluna inteira, duas horizontais empilham. O JS duplica as colunas para o loop.
     O [data-ampliar] em cada card liga o lightbox; data-ampliada aponta a versão grande da foto. -->
<div class="c-galeria">
  <div class="c-galeria__trilho">
    <div class="c-galeria__coluna" style="width: 220px">
      <figure class="c-galeria__card" data-ampliar data-ampliada="foto-1-grande.jpg">
        <img class="c-galeria__foto" src="foto-1.jpg" alt="Ciclorama branco montado">
      </figure>
    </div>
    <div class="c-galeria__coluna" style="width: 320px">
      <figure class="c-galeria__card" data-ampliar data-ampliada="foto-2-grande.jpg">
        <img class="c-galeria__foto" src="foto-2.jpg" alt="Mesa de maquiagem com espelho">
      </figure>
      <figure class="c-galeria__card" data-ampliar data-ampliada="foto-3-grande.jpg">
        <img class="c-galeria__foto" src="foto-3.jpg" alt="Softbox em tripé">
      </figure>
    </div>
  </div>
</div>
<p class="c-galeria__legenda">Arraste para explorar — toque para ampliar.</p>

<!-- o lightbox vive uma vez por página, no fim do body -->
<div class="c-lightbox" id="c-lightbox" role="dialog" aria-modal="true" aria-label="Imagem ampliada" hidden>
  <button type="button" class="c-lightbox__fechar" aria-label="Fechar">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12"/></svg>
  </button>
  <div class="c-lightbox__palco"></div>
</div>

Já rodou em

  • trento-pedro · home v3 — seção 7, reescrita da galeria na rodada 2 (15/09/2026)

Exige

  • react
  • framer-motion (motion, useScroll, useTransform, AnimatePresence, drag)
  • lucide-react (X)
  • cn

Como se configura

  • --c-gal-fundo
  • --c-gal-linha
  • --c-gal-raio
  • --c-gal-veu
  • --c-gal-tinta

A ficha

O que o 21st.dev entregou

Recebido em 15/09/2026, depois do teste no iPhone, para refazer a galeria (especificação §10.4, linha 7).

Dependências: React, Framer Motion (motion, useScroll, useTransform, AnimatePresence, drag), lucide-react (ícone X), cn do shadcn. Nada disso vai para a Trento.

Como funciona:

  • Faixa: grade CSS com grid-flow-col e colunas automáticas de no mínimo 15rem; cada item recebe classes de span (md:col-span-2, md:row-span-2) que só valem a partir de md — no celular todos os cards ficam iguais.
  • Arraste: a faixa inteira é arrastável na horizontal (drag="x"), com limite calculado pela largura do conteúdo e elasticidade de 5% nas pontas.
  • Entrada: cards aparecem em cascata (atraso de 0,1 s entre eles) com mola; título e descrição entram e saem com a rolagem.
  • Card: foto cobrindo o card, cantos rounded-xl, borda e sombra; no hover, zoom de 5% na foto e título + descrição sobem sobre um degradê escuro.
  • Modal: fundo preto a 80% com desfoque; a foto em object-contain até 90vh; botão X no canto superior direito; clicar fora fecha. Enter no card também abre.

O que não cabe na Trento, e o que muda na reescrita:

  • Spans iguais no celular não resolvem o pedido do Rafa de casar o formato do card com a foto: a reescrita monta colunas — foto vertical ocupa a coluna inteira, duas horizontais empilham — e cada card fica na proporção da própria foto.
  • Não anda sozinha: a Trento pede deriva lenta contínua em loop, parando no toque.
  • Hover não existe no celular: título e descrição sobre a foto saem; fica o alt.
  • O modal vira o lightbox compartilhado com os carrosséis (mesmo botão de fechar, toque fora, Esc).
  • As fotos de paisagem do demo não entram: são as 4 do Canva mais os placeholders do Unsplash escolhidos pelo Rafa.

O que a adaptação da OCTA mudou

  • Cada card fica na proporção da própria foto, em colunas — não em spans fixos
  • Deriva lenta contínua em loop, que para no toque
  • O modal virou lightbox compartilhado (toque fora, Esc, mesmo botão de fechar)

Lacunas do original

  • O layout bento só vale a partir de md — no celular todos os cards ficam iguais
  • Título e descrição sobre a foto aparecem no hover, que não existe no celular
  • Não anda sozinha: sem arraste, a faixa fica parada