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
O código
galeria.css + galeria.js + lightbox.css + lightbox.js + markup.html
/* ===== 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>
06-galeria-bento.prompt.md
/* ===== 06-galeria-bento.prompt.md ================================================== */
You are given a task to integrate an existing React component in the codebase
The codebase should support:
- shadcn project structure
- Tailwind CSS
- Typescript
If it doesn't, provide instructions on how to setup project via shadcn CLI, install Tailwind or Typescript.
Determine the default path for components and styles.
If default path for components is not /components/ui, provide instructions on why it's important to create this folder
Copy-paste this component to /components/ui folder:
```tsx
bento-gallery.tsx
"use client"
import React, { useRef, useState, useEffect } from "react"
import {
motion,
useScroll,
useTransform,
AnimatePresence,
} from "framer-motion"
import { cn } from "@/lib/utils" // Assumes a 'lib/utils.ts' file for 'cn'
import { X } from "lucide-react"
// Defines the structure for each image item in the gallery
type ImageItem = {
id: number | string
title: string
desc: string
url: string
span: string // Tailwind CSS grid span classes (e.g., "md:col-span-2")
}
// Defines the props for the main gallery component
interface InteractiveImageBentoGalleryProps {
imageItems: ImageItem[]
title: string
description: string
}
// Animation variants for the container to stagger children
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
}
// Animation variants for each gallery item
const itemVariants = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 100, damping: 15 },
},
}
// Modal component for displaying the selected image
const ImageModal = ({
item,
onClose,
}: {
item: ImageItem
onClose: () => void
}) => {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="relative w-full max-w-4xl p-4"
onClick={(e) => e.stopPropagation()}
>
<img
src={item.url}
alt={item.title}
className="h-auto max-h-[90vh] w-full rounded-lg object-contain"
/>
</motion.div>
<button
onClick={onClose}
className="absolute right-4 top-4 text-white/80 transition-colors hover:text-white"
aria-label="Close image view"
>
<X size={24} />
</button>
</motion.div>
)
}
// Main gallery component
const InteractiveImageBentoGallery: React.FC<
InteractiveImageBentoGalleryProps
> = ({ imageItems, title, description }) => {
const [selectedItem, setSelectedItem] = useState<ImageItem | null>(null)
const [dragConstraint, setDragConstraint] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const targetRef = useRef<HTMLDivElement>(null)
// Calculate the draggable area constraint
useEffect(() => {
const calculateConstraints = () => {
if (gridRef.current && containerRef.current) {
const containerWidth = containerRef.current.offsetWidth
const gridWidth = gridRef.current.scrollWidth
// The '- 32' provides some padding at the end
const newConstraint = Math.min(0, containerWidth - gridWidth - 32)
setDragConstraint(newConstraint)
}
}
calculateConstraints()
window.addEventListener("resize", calculateConstraints)
return () => window.removeEventListener("resize", calculateConstraints)
}, [imageItems])
// Framer Motion scroll animations
const { scrollYProgress } = useScroll({
target: targetRef,
offset: ["start end", "end start"],
})
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0])
const y = useTransform(scrollYProgress, [0, 0.2], [30, 0])
return (
<section
ref={targetRef}
className="relative w-full overflow-hidden bg-background py-16 sm:py-24"
>
<motion.div
style={{ opacity, y }}
className="container mx-auto px-4 text-center"
>
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
{title}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
{description}
</p>
</motion.div>
<div
ref={containerRef}
className="relative mt-12 w-full cursor-grab active:cursor-grabbing"
>
<motion.div
className="w-max"
drag="x"
dragConstraints={{ left: dragConstraint, right: 0 }}
dragElastic={0.05}
>
<motion.div
ref={gridRef}
className="grid auto-cols-[minmax(15rem,1fr)] grid-flow-col gap-4 px-4 md:px-8"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
>
{imageItems.map((item) => (
<motion.div
key={item.id}
variants={itemVariants}
className={cn(
"group relative flex h-full min-h-[15rem] w-full min-w-[15rem] cursor-pointer items-end overflow-hidden rounded-xl border bg-card p-4 shadow-sm transition-shadow duration-300 ease-in-out hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
item.span,
)}
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
onClick={() => setSelectedItem(item)}
onKeyDown={(e) => e.key === "Enter" && setSelectedItem(item)}
tabIndex={0}
aria-label={`View ${item.title}`}
>
<img
src={item.url}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<div className="relative z-10 translate-y-4 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100">
<h3 className="text-lg font-bold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-white/80">{item.desc}</p>
</div>
</motion.div>
))}
</motion.div>
</motion.div>
</div>
<AnimatePresence>
{selectedItem && (
<ImageModal item={selectedItem} onClose={() => setSelectedItem(null)} />
)}
</AnimatePresence>
</section>
)
}
export default InteractiveImageBentoGallery
demo.tsx
import InteractiveImageBentoGallery from "@/components/ui/bento-gallery"
// Sample data for the image gallery
const imageItems = [
{
id: 1,
title: "Mountain Vista",
desc: "Serenity above the clouds.",
url: "https://cdn.21st.dev/assets/mirror/61/611dfffb9431b3ac4206a6e7a8874496747263ce8535985238d0557503e7dd48.jpg",
span: "md:col-span-2 md:row-span-2",
},
{
id: 2,
title: "Coastal Arch",
desc: "Where the land meets the sea.",
url: "https://cdn.21st.dev/assets/mirror/97/97103cb7b8ac5adbbd3e64c7410560b04a83b056daa580ee1b0353f774ec8d3d.jpg",
span: "md:row-span-1",
},
{
id: 3,
title: "Forest Canopy",
desc: "Sunlight filtering through leaves.",
url: "https://cdn.21st.dev/assets/mirror/e5/e568c4b96c7f3aee720ea6b72026a629c5122bf9a7d887d62ab193f349eb5bfe.jpg",
span: "md:row-span-1",
},
{
id: 4,
title: "Desert Dunes",
desc: "Golden sands under the sun.",
url: "https://cdn.21st.dev/assets/mirror/d5/d505ed9bd1290a57ed0a97d1405d5f045332ac4b3bdca717e1fb2adbd07e6af1.jpg",
span: "md:row-span-2",
},
{
id: 5,
title: "City at Night",
desc: "A vibrant urban landscape.",
url: "https://cdn.21st.dev/assets/mirror/6a/6a375b80a961a5dfa61d0e0b3edaa47dbf6267189e2fe93ca27eb12e5ec1f5ce.jpg",
span: "md:row-span-1",
},
{
id: 6,
title: "Misty Lake",
desc: "Morning fog over calm waters.",
url: "https://cdn.21st.dev/assets/mirror/81/81f20f0ed8b6b081f4b8b4da2370e787a1aaa878bdb371a004b6339f6dd70d81.jpg",
span: "md:col-span-2 md:row-span-1",
},
]
export default function InteractiveImageBentoGalleryDemo() {
return (
<div className="w-full antialiased">
<InteractiveImageBentoGallery
imageItems={imageItems}
title="Curated Moments"
description="A collection of stunning landscapes. Drag to explore, click to expand."
/>
</div>
)
}
```
Install NPM dependencies:
```bash
lucide-react, framer-motion
```
Implementation Guidelines
1. Analyze the component structure and identify all required dependencies
2. Review the component's argumens and state
3. Identify any required context providers or hooks and install them
4. Questions to Ask
- What data/props will be passed to this component?
- Are there any specific state management requirements?
- Are there any required assets (images, icons, etc.)?
- What is the expected responsive behavior?
- What is the best place to use this component in the app?
Steps to integrate
0. Copy paste all the code above in the correct directories
1. Install external dependencies
2. Fill image assets with Unsplash stock images you know exist
3. Use lucide-react icons for svgs or logos if component requires them
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-cole 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 demd— 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-containaté 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