Carrossel coverflow 3D
Card central inteiro e vizinhos girados em perspectiva, com setas, pontos, passagem automática e swipe. React puro, sem dependências.
usa o CAR-01
O código
coverflow.css + coverflow.js + markup.html
/* ===== coverflow.css =============================================================== */
/* CAR-01 · Carrossel coverflow 3D — reescrito do original do 21st.dev sem React.
Card central inteiro; ±1 e ±2 girados em perspectiva, menores e escurecidos; o resto some no
centro. Mobile-first: o card encolhe para 76 vw mantendo a proporção 330 × 500, e a partir de
768 px vale o tamanho da referência.
O conteúdo do card é livre — o original é um depoimento, mas serve a qualquer cartão. */
.c-coverflow {
--c-car-fundo-card: #141414;
--c-car-linha: rgba(235, 235, 235, .14);
--c-car-tinta: #ebebeb;
--c-car-destaque: #e8ad32;
--c-car-raio: 18px;
--c-car-perspectiva: 1400px;
--c-car-largura: min(330px, 76vw);
--c-car-altura: calc(var(--c-car-largura) * 500 / 330);
--c-car-gutter: 16px;
--c-car-passo: calc(var(--c-car-largura) * .864);
--c-car-passo-2: calc(var(--c-car-largura) * 1.545);
--c-car-curva: cubic-bezier(.25, 1, .5, 1);
position: relative;
padding: 16px 0 8px;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
@media (min-width: 768px) { .c-coverflow { --c-car-largura: 330px; } }
.c-coverflow__palco {
position: relative;
height: calc(var(--c-car-altura) + 48px);
perspective: var(--c-car-perspectiva);
touch-action: pan-y; /* o dedo na vertical rola a página; na horizontal, passa o card */
}
.c-coverflow__card {
position: absolute;
top: 50%;
left: 50%;
width: var(--c-car-largura);
height: var(--c-car-altura);
margin: calc(var(--c-car-altura) / -2) 0 0 calc(var(--c-car-largura) / -2);
border: 1px solid var(--c-car-linha);
border-radius: var(--c-car-raio);
overflow: hidden;
background: var(--c-car-fundo-card);
transform: translate3d(0, 0, 0) scale(.4) rotateY(0deg);
transform-origin: center center;
opacity: 0;
z-index: 0;
filter: brightness(.4) blur(2px);
pointer-events: none;
transition: transform .8s var(--c-car-curva), opacity .8s var(--c-car-curva), filter .8s var(--c-car-curva);
-webkit-tap-highlight-color: transparent;
}
.c-coverflow__card.is-central { transform: translate3d(0, 0, 0) scale(1) rotateY(0deg); opacity: 1; z-index: 30; filter: none; pointer-events: auto; }
.c-coverflow__card.is-dir-1 { transform: translate3d(var(--c-car-passo), 0, 0) scale(.84) rotateY(-24deg); opacity: .65; z-index: 20; filter: brightness(.75); pointer-events: auto; cursor: pointer; }
.c-coverflow__card.is-dir-2 { transform: translate3d(var(--c-car-passo-2), 0, 0) scale(.68) rotateY(-38deg); opacity: .38; z-index: 10; filter: brightness(.55) blur(1px); pointer-events: auto; cursor: pointer; }
.c-coverflow__card.is-esq-1 { transform: translate3d(calc(-1 * var(--c-car-passo)), 0, 0) scale(.84) rotateY(24deg); opacity: .65; z-index: 20; filter: brightness(.75); pointer-events: auto; cursor: pointer; }
.c-coverflow__card.is-esq-2 { transform: translate3d(calc(-1 * var(--c-car-passo-2)), 0, 0) scale(.68) rotateY(38deg); opacity: .38; z-index: 10; filter: brightness(.55) blur(1px); pointer-events: auto; cursor: pointer; }
/* conteúdo do card: foto cobrindo, véu para o texto ter contraste, e o texto só no central */
.c-coverflow__foto { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.c-coverflow__veu {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(180deg, rgba(0, 0, 0, .40) 0%, rgba(0, 0, 0, .10) 25%, rgba(0, 0, 0, .68) 60%, rgba(0, 0, 0, .96) 100%);
}
.c-coverflow__conteudo {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: 100%;
padding: 20px 18px 22px;
text-align: center;
color: var(--c-car-tinta);
opacity: 0;
transform: translateY(16px);
transition: opacity .5s ease, transform .5s ease;
}
.c-coverflow__card.is-central .c-coverflow__conteudo { opacity: 1; transform: none; }
.c-coverflow__nome { font-size: 1.65rem; font-weight: 700; line-height: 1.1; text-transform: uppercase; letter-spacing: .02em; text-shadow: 0 3px 12px rgba(0, 0, 0, .95); }
.c-coverflow__papel { font-size: .9rem; font-weight: 600; line-height: 1.2; text-transform: uppercase; letter-spacing: .06em; text-shadow: 0 3px 10px rgba(0, 0, 0, .9); }
.c-coverflow__traco { width: 34px; height: 2px; margin: 5px auto 4px; border-radius: 2px; background-color: var(--c-car-destaque); }
.c-coverflow__texto { max-width: 280px; margin: 0 auto 6px; font-size: .88rem; line-height: 1.35; text-shadow: 0 2px 8px rgba(0, 0, 0, .9); }
/* setas redondas nas laterais e os pontos de paginação (o JS cria os pontos) */
.c-coverflow__seta {
position: absolute;
z-index: 40;
top: calc(16px + (var(--c-car-altura) + 48px) / 2);
transform: translateY(-50%);
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--c-car-linha);
border-radius: 50%;
background-color: rgba(235, 235, 235, .08);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
color: var(--c-car-tinta);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color .2s ease;
}
.c-coverflow__seta:hover { background-color: rgba(235, 235, 235, .16); }
.c-coverflow__seta--anterior { left: var(--c-car-gutter); }
.c-coverflow__seta--proxima { right: var(--c-car-gutter); }
.c-coverflow__seta svg { width: 20px; height: 20px; }
.c-coverflow__pontos { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; }
.c-coverflow__ponto {
width: 8px; height: 8px; padding: 0; border: 0; border-radius: 999px;
background-color: rgba(235, 235, 235, .25);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: width .3s ease, background-color .3s ease;
}
.c-coverflow__ponto[aria-selected="true"] { width: 28px; background-color: var(--c-car-destaque); }
/* o rótulo de leitor de tela, fora da tela mas na árvore */
.c-coverflow .c-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) {
.c-coverflow__card, .c-coverflow__conteudo { transition: none; }
}
/* ===== coverflow.js ================================================================ */
/* CAR-01 · Coverflow 3D — reescrito do original do 21st.dev sem React.
Cinco estados por classe (central, ±1, ±2; o resto some no centro), setas, pontos de paginação,
passagem automática a cada 5 s, setas do teclado, swipe por pointer events e wheel horizontal.
A passagem para quando: a seção sai da tela, a aba sai de foco, o mouse está em cima, o dedo
está no card, o foco de TECLADO está num card (padrão APG — senão o foco, que segue o centro,
pularia de card a cada 5 s), "reduzir movimento" está ligado, ou passou menos de 8 s de um toque.
Três coisas que o original não tem e que foram pagas em produção:
- **tabindex itinerante:** só o card central é focável, e o foco acompanha o centro;
- **fim de arraste não é toque:** o clique depois de um swipe é engolido, senão um arraste
disparava a ação do card;
- **o lateral só vem ao centro:** clicar num vizinho não executa a ação dele.
Nada de `aria-haspopup` nos cards: `role="group"` não o admite (ARIA 1.2 o permite em button,
link, menuitem, combobox, gridcell e afins) e o axe o acusa como erro. Quem anuncia a ação do
card central é o `aria-describedby`.
API pública, em cada elemento `.c-coverflow` (use para integrar com modal, vídeo, o que for):
el.__cCoverflow.irPara(i) · .pausar(ms) · .pausado(true|false) · .atual() · .total() */
(function () {
'use strict';
var INTERVALO = 5000;
var PAUSA_TOQUE = 8000;
var SWIPE = 45;
var CLASSES = ['is-central', 'is-dir-1', 'is-dir-2', 'is-esq-1', 'is-esq-2'];
var contador = 0;
function reduzirMovimento() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
function preparar(cf) {
var palco = cf.querySelector('.c-coverflow__palco');
var cards = Array.prototype.slice.call(cf.querySelectorAll('.c-coverflow__card'));
var anterior = cf.querySelector('.c-coverflow__seta--anterior');
var proxima = cf.querySelector('.c-coverflow__seta--proxima');
var pontos = cf.querySelector('.c-coverflow__pontos');
var n = cards.length;
if (!palco || !n) { return; }
var atual = 0, visivel = false, pausadoAte = 0, travado = false, mouseEmCima = false, swipou = false, dedo = false;
var timer = null, x0 = null, botoes = [];
var acao = cf.getAttribute('data-acao') || ''; // texto anunciado para o card central; vazio = sem ação
cards.forEach(function (c, i) {
c.setAttribute('role', 'group');
c.setAttribute('aria-roledescription', 'slide');
c.setAttribute('aria-label', (i + 1) + ' de ' + n);
});
/* a descrição da ação do card central é um texto fora da tela e FORA da árvore (aria-hidden):
não vira frase solta no modo de leitura, e o aria-describedby lê o texto que referencia mesmo
oculto (accname 1.2) */
var dica = null;
if (acao) {
dica = document.createElement('span');
dica.id = 'c-coverflow-dica-' + (++contador);
dica.className = 'c-sr';
dica.setAttribute('aria-hidden', 'true');
dica.textContent = acao;
cf.appendChild(dica);
}
if (pontos) {
cards.forEach(function (_, i) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'c-coverflow__ponto';
b.setAttribute('role', 'tab');
b.setAttribute('aria-label', 'Item ' + (i + 1) + ' de ' + n);
b.addEventListener('click', function () { pausarPorToque(); irPara(i); });
pontos.appendChild(b);
botoes.push(b);
});
}
function aplicar() {
var focado = cards.indexOf(document.activeElement);
cards.forEach(function (c, i) {
var d = (i - atual + n) % n;
var k = d === 0 ? 'is-central' : d === 1 ? 'is-dir-1' : d === 2 ? 'is-dir-2'
: d === n - 1 ? 'is-esq-1' : d === n - 2 ? 'is-esq-2' : null;
CLASSES.forEach(function (cl) { if (cl !== k) { c.classList.remove(cl); } });
if (k) { c.classList.add(k); }
c.setAttribute('aria-hidden', d === 0 ? 'false' : 'true');
if (d === 0) {
c.tabIndex = 0;
if (dica) { c.setAttribute('aria-describedby', dica.id); }
} else {
c.tabIndex = -1;
c.removeAttribute('aria-describedby');
}
});
// o foco segue o centro: o card com foco não sai para a lateral escondido
if (focado >= 0 && focado !== atual) { cards[atual].focus({ preventScroll: true }); }
botoes.forEach(function (b, i) { b.setAttribute('aria-selected', i === atual ? 'true' : 'false'); });
cf.setAttribute('data-atual', String(atual));
}
function irPara(i) { atual = ((i % n) + n) % n; aplicar(); }
function pausarPorToque(ms) { pausadoAte = Date.now() + (ms || PAUSA_TOQUE); agendar(); }
function focoDeTeclado() {
if (cards.indexOf(document.activeElement) < 0) { return false; }
try { return document.activeElement.matches(':focus-visible'); } catch (e) { return true; }
}
function podeAndar() {
return visivel && !travado && !mouseEmCima && !dedo && !document.hidden
&& !reduzirMovimento() && Date.now() >= pausadoAte && !focoDeTeclado();
}
function agendar() {
clearTimeout(timer);
var espera = Math.max(INTERVALO, pausadoAte - Date.now());
timer = setTimeout(function () { if (podeAndar()) { irPara(atual + 1); } agendar(); }, espera);
}
if (anterior) { anterior.addEventListener('click', function () { pausarPorToque(); irPara(atual - 1); }); }
if (proxima) { proxima.addEventListener('click', function () { pausarPorToque(); irPara(atual + 1); }); }
cards.forEach(function (c, i) {
c.addEventListener('click', function (e) {
if (swipou) { swipou = false; e.preventDefault(); e.stopPropagation(); return; } // fim de arraste não é toque
if (i !== atual) { e.stopPropagation(); pausarPorToque(); irPara(i); } // o lateral só vem ao centro
});
});
cf.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { mouseEmCima = true; } });
cf.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') { mouseEmCima = false; } });
/* o dedo no card pausa enquanto estiver lá e 8 s depois de soltar; sem a captura implícita do
toque, um botão solto fora do palco deixaria o dedo preso */
palco.addEventListener('pointerdown', function (e) {
if (e.button === 0) { x0 = e.clientX; swipou = false; dedo = e.pointerType !== 'mouse'; pausarPorToque(); }
});
palco.addEventListener('pointerup', function (e) {
dedo = false;
if (x0 === null) { return; }
var d = e.clientX - x0;
x0 = null;
pausarPorToque();
if (Math.abs(d) > SWIPE) { swipou = true; irPara(d < 0 ? atual + 1 : atual - 1); }
});
palco.addEventListener('pointercancel', function () { x0 = null; dedo = false; pausarPorToque(); });
/* no PC, deslizar na horizontal no trackpad passa os cards — só o wheel horizontal, com trava de
600 ms entre passos; o vertical segue rolando a página */
var rolagem = 0, travaAte = 0;
cf.addEventListener('wheel', function (e) {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) { return; }
e.preventDefault();
if (Date.now() < travaAte) { return; }
rolagem += e.deltaX;
if (Math.abs(rolagem) >= 40) {
travaAte = Date.now() + 600;
pausarPorToque();
irPara(rolagem > 0 ? atual + 1 : atual - 1);
rolagem = 0;
}
}, { passive: false });
/* o teclado só responde com a seção na tela — é o que tira o conflito do ouvinte global do
original, que roubava a seta de outro componente da mesma página */
document.addEventListener('keydown', function (e) {
if (!visivel) { return; }
if (e.key === 'ArrowRight') { pausarPorToque(); irPara(atual + 1); }
else if (e.key === 'ArrowLeft') { pausarPorToque(); irPara(atual - 1); }
});
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entradas) { visivel = entradas[0].isIntersecting; }, { threshold: 0.4 }).observe(cf);
} else {
visivel = true;
}
cf.__cCoverflow = {
irPara: irPara,
pausar: function (ms) { pausarPorToque(ms); },
pausado: function (sim) { travado = !!sim; agendar(); },
atual: function () { return atual; },
total: function () { return n; }
};
aplicar();
agendar();
}
function iniciar() { Array.prototype.forEach.call(document.querySelectorAll('.c-coverflow'), preparar); }
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();
/* ===== markup.html ================================================================= */
<!-- CAR-01 · Coverflow. Os pontos de paginação são criados pelo JS dentro de .c-coverflow__pontos.
data-acao é opcional: o texto anunciado para o card central quando ele tem ação (abrir um modal,
por exemplo). Sem ele, o card não recebe aria-describedby. O conteúdo do card é livre. -->
<div class="c-coverflow" data-acao="Ampliar o depoimento">
<div class="c-coverflow__palco">
<figure class="c-coverflow__card">
<img class="c-coverflow__foto" src="foto-1.jpg" alt="">
<span class="c-coverflow__veu" aria-hidden="true"></span>
<figcaption class="c-coverflow__conteudo">
<span class="c-coverflow__nome">Marina Alves</span>
<span class="c-coverflow__papel">Fotógrafa</span>
<span class="c-coverflow__traco" aria-hidden="true"></span>
<p class="c-coverflow__texto">Montei o ensaio em meia hora. A luz já estava pronta.</p>
</figcaption>
</figure>
<figure class="c-coverflow__card">
<img class="c-coverflow__foto" src="foto-2.jpg" alt="">
<span class="c-coverflow__veu" aria-hidden="true"></span>
<figcaption class="c-coverflow__conteudo">
<span class="c-coverflow__nome">Rodrigo Dias</span>
<span class="c-coverflow__papel">Diretor</span>
<span class="c-coverflow__traco" aria-hidden="true"></span>
<p class="c-coverflow__texto">Gravei três episódios num dia só.</p>
</figcaption>
</figure>
<!-- cinco ou mais cards deixam os dois vizinhos de cada lado visíveis -->
<button type="button" class="c-coverflow__seta c-coverflow__seta--anterior" aria-label="Anterior">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button type="button" class="c-coverflow__seta c-coverflow__seta--proxima" aria-label="Próximo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>
</button>
</div>
<div class="c-coverflow__pontos" role="tablist" aria-label="Itens"></div>
</div>
08-coverflow-carrossel.tsx
/* ===== 08-coverflow-carrossel.tsx ================================================== */
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
// Inline Icons (Zero external dependencies)
const ChevronLeftIcon = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
);
const ChevronRightIcon = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
);
const ArrowRightIcon = () => (
<svg width="13" height="13" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
);
export interface CarouselItem {
tag?: string;
titleLine1: string;
titleLine2?: string;
desc?: string;
img: string;
ctaText?: string;
ctaUrl?: string;
}
export interface CoverFlowCarouselProps {
items?: CarouselItem[];
sectionLabel?: string;
autoplay?: boolean;
autoplayDelay?: number;
className?: string;
onCtaClick?: (item: CarouselItem) => void;
}
export const defaultDishes: CarouselItem[] = [
{
tag: "#Signature",
titleLine1: "BUTTER CHICKEN",
titleLine2: "– DELHI HERITAGE",
desc: "Velvety roasted tomato and fenugreek gravy with tender charred chicken",
img: "https://cdn.21st.dev/assets/mirror/84/84cb320f9692895054c9e1774ca48848f1c9c62ccba43ad8ad0b186460bb3751.jpg",
ctaText: "View Menu",
ctaUrl: "#",
},
{
tag: "#ChefSpecial",
titleLine1: "TANDOORI CHOPS",
titleLine2: "– SMOKED SPICE",
desc: "Grass-fed lamb chops charred in live charcoal tandoor with Kashmiri spices",
img: "https://cdn.21st.dev/assets/mirror/a4/a4712dee84e12432f1d3a1a3234914c0281c9bb12692e4bc8da5eaa4355bec33.jpg",
ctaText: "View Menu",
ctaUrl: "#",
},
{
tag: "#Vegetarian",
titleLine1: "PANEER TIKKA",
titleLine2: "– CLAY ROASTED",
desc: "Artisan cottage cheese marinated in spiced yogurt, bell peppers & saffron",
img: "https://cdn.21st.dev/assets/mirror/56/56a6950cf4a436af231cf7cd707189e121e65934066f851c744bdab0cfee64d4.jpg",
ctaText: "View Menu",
ctaUrl: "#",
},
{
tag: "#CoastalCatch",
titleLine1: "MALABAR PRAWNS",
titleLine2: "– COCONUT GRAVY",
desc: "Jumbo wild tiger prawns simmered in fragrant curry leaves and coconut milk",
img: "https://cdn.21st.dev/assets/mirror/a3/a32877b070c563bbbcf54b6104761b1516814625209c806ee8b60f8a69598cd1.jpg",
ctaText: "View Menu",
ctaUrl: "#",
},
{
tag: "#ArtisanBake",
titleLine1: "TRUFFLE NAAN",
titleLine2: "– CHARCOAL OVEN",
desc: "Crispy puffed leavened bread brushed with pure ghee and black winter truffle",
img: "https://cdn.21st.dev/assets/mirror/5c/5c1b6f03cc2ace649f9025f304ca4fdce74cd413504e65a00ef3f68152e8ed92.jpg",
ctaText: "View Menu",
ctaUrl: "#",
},
];
export function CoverFlowCarousel({
items = defaultDishes,
sectionLabel = "BEST SELLERS",
autoplay = true,
autoplayDelay = 5000,
className = "",
onCtaClick,
}: CoverFlowCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const touchStartX = useRef(0);
const total = items.length;
const nextSlide = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % total);
}, [total]);
const prevSlide = useCallback(() => {
setCurrentIndex((prev) => (prev - 1 + total) % total);
}, [total]);
const goToSlide = (idx: number) => {
setCurrentIndex(idx % total);
};
useEffect(() => {
if (!autoplay || isHovered || total <= 1) return;
const interval = setInterval(nextSlide, autoplayDelay);
return () => clearInterval(interval);
}, [autoplay, autoplayDelay, isHovered, nextSlide, total]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") prevSlide();
if (e.key === "ArrowRight") nextSlide();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [nextSlide, prevSlide]);
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
const diff = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(diff) > 45) {
if (diff < 0) nextSlide();
else prevSlide();
}
};
if (!items || items.length === 0) return null;
return (
<section
className={`relative w-full min-h-[760px] flex items-center justify-center overflow-hidden py-12 select-none ${className}`}
style={{
backgroundColor: "#0c0a09",
color: "#ffffff",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Background Ambience */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
<img
src={items[currentIndex]?.img}
alt="ambience background"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
filter: "brightness(0.22) blur(32px)",
transform: "scale(1.15)",
transition: "opacity 1000ms ease, filter 1000ms ease",
}}
/>
<div
className="absolute inset-0"
style={{
background: "radial-gradient(circle at center, rgba(12,10,9,0.3) 0%, rgba(12,10,9,0.92) 100%)",
}}
/>
</div>
<div className="relative w-full max-w-6xl mx-auto px-4 z-10 flex flex-col items-center">
{/* Eyebrow */}
{sectionLabel && (
<div className="flex items-center gap-3 mb-8">
<span style={{ width: "36px", height: "1px", background: "linear-gradient(90deg, transparent, #c5a880)" }} />
<h3
style={{
fontSize: "0.75rem",
fontWeight: 700,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#c5a880",
margin: 0,
}}
>
{sectionLabel}
</h3>
<span style={{ width: "36px", height: "1px", background: "linear-gradient(90deg, #c5a880, transparent)" }} />
</div>
)}
{/* 3D Coverflow Stage */}
<div
className="relative w-full h-[520px] flex justify-center items-center mb-8"
style={{ perspective: "1400px" }}
>
{items.map((item, idx) => {
const offset = (idx - currentIndex + total) % total;
let transform = "translateX(0px) scale(0.4) rotateY(0deg)";
let opacity = 0;
let zIndex = 0;
let filter = "brightness(0.4) blur(2px)";
let isCenter = false;
if (offset === 0) {
isCenter = true;
transform = "translateX(0px) scale(1) rotateY(0deg)";
opacity = 1;
zIndex = 30;
filter = "brightness(1)";
} else if (offset === 1) {
transform = "translateX(285px) scale(0.84) rotateY(-24deg)";
opacity = 0.65;
zIndex = 20;
filter = "brightness(0.75)";
} else if (offset === 2) {
transform = "translateX(510px) scale(0.68) rotateY(-38deg)";
opacity = 0.38;
zIndex = 10;
filter = "brightness(0.55) blur(1px)";
} else if (offset === total - 1) {
transform = "translateX(-285px) scale(0.84) rotateY(24deg)";
opacity = 0.65;
zIndex = 20;
filter = "brightness(0.75)";
} else if (offset === total - 2) {
transform = "translateX(-510px) scale(0.68) rotateY(38deg)";
opacity = 0.38;
zIndex = 10;
filter = "brightness(0.55) blur(1px)";
}
return (
<div
key={idx}
onClick={() => !isCenter && goToSlide(idx)}
style={{
position: "absolute",
width: "330px",
height: "500px",
borderRadius: "18px",
overflow: "hidden",
backgroundColor: "#171311",
border: "1px solid rgba(255, 255, 255, 0.12)",
transform,
opacity,
zIndex,
filter,
transformOrigin: "center center",
transition: "all 800ms cubic-bezier(0.25, 1, 0.5, 1)",
boxShadow: isCenter
? "0 25px 60px rgba(0,0,0,0.9), 0 0 35px rgba(197,168,128,0.25)"
: "0 15px 35px rgba(0,0,0,0.5)",
cursor: isCenter ? "default" : "pointer",
}}
>
{/* Photo */}
<img
src={item.img}
alt={item.titleLine1}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Dark Vignette Overlay */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.1) 25%, rgba(0,0,0,0.68) 60%, rgba(0,0,0,0.96) 100%)",
pointerEvents: "none",
zIndex: 10,
}}
/>
{/* Content Overlay */}
<div
style={{
position: "relative",
width: "100%",
height: "100%",
padding: "20px 18px 22px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
textAlign: "center",
zIndex: 20,
opacity: isCenter ? 1 : 0,
transform: isCenter ? "translateY(0px)" : "translateY(16px)",
transition: "opacity 500ms ease, transform 500ms ease",
pointerEvents: isCenter ? "auto" : "none",
}}
>
{/* Tag */}
<div style={{ textAlign: "right", width: "100%", paddingRight: "4px" }}>
<span
style={{
display: "inline-block",
fontSize: "0.78rem",
fontWeight: 600,
letterSpacing: "0.06em",
color: "rgba(255,255,255,0.9)",
textShadow: "0 2px 6px rgba(0,0,0,0.8)",
}}
>
{item.tag}
</span>
</div>
{/* Body Content */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "3px",
marginTop: "auto",
paddingBottom: "4px",
}}
>
<h2
style={{
fontSize: "1.65rem",
fontWeight: 900,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "#ffffff",
margin: 0,
lineHeight: 1.1,
textShadow: "0 3px 12px rgba(0,0,0,0.95)",
}}
>
{item.titleLine1}
</h2>
{item.titleLine2 && (
<span
style={{
fontSize: "1.1rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "#f3f0ea",
lineHeight: 1.2,
textShadow: "0 3px 10px rgba(0,0,0,0.9)",
}}
>
{item.titleLine2}
</span>
)}
<div
style={{
width: "34px",
height: "2px",
backgroundColor: "#c5a880",
borderRadius: "2px",
margin: "5px auto 4px",
boxShadow: "0 0 8px rgba(197,168,128,0.7)",
}}
/>
{item.desc && (
<p
style={{
fontSize: "0.82rem",
fontStyle: "italic",
color: "rgba(255,255,255,0.9)",
maxWidth: "280px",
margin: "0 0 10px",
lineHeight: 1.3,
textShadow: "0 2px 8px rgba(0,0,0,0.9)",
}}
>
{item.desc}
</p>
)}
<a
href={item.ctaUrl || "#"}
onClick={(e) => {
if (onCtaClick) {
e.preventDefault();
onCtaClick(item);
}
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "7px 18px",
borderRadius: "9999px",
background: "linear-gradient(135deg, #c5a880 0%, #a48256 100%)",
color: "#110d0c",
fontSize: "0.72rem",
fontWeight: 800,
letterSpacing: "0.14em",
textTransform: "uppercase",
textDecoration: "none",
boxShadow: "0 4px 14px rgba(0,0,0,0.4), 0 0 15px rgba(197,168,128,0.3)",
cursor: "pointer",
transition: "transform 200ms ease, box-shadow 200ms ease",
}}
>
<span>{item.ctaText || "View Menu"}</span>
<ArrowRightIcon />
</a>
</div>
</div>
</div>
);
})}
</div>
{/* Navigation Arrows */}
<button
onClick={prevSlide}
aria-label="Previous dish"
style={{
position: "absolute",
left: "24px",
top: "50%",
transform: "translateY(-50%)",
width: "46px",
height: "46px",
borderRadius: "50%",
backgroundColor: "rgba(0,0,0,0.55)",
border: "1px solid rgba(255,255,255,0.2)",
color: "#ffffff",
display: "flex",
alignItems: "center",
justifyContent: "center",
backdropFilter: "blur(8px)",
cursor: "pointer",
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
zIndex: 40,
transition: "all 200ms ease",
}}
>
<ChevronLeftIcon />
</button>
<button
onClick={nextSlide}
aria-label="Next dish"
style={{
position: "absolute",
right: "24px",
top: "50%",
transform: "translateY(-50%)",
width: "46px",
height: "46px",
borderRadius: "50%",
backgroundColor: "rgba(0,0,0,0.55)",
border: "1px solid rgba(255,255,255,0.2)",
color: "#ffffff",
display: "flex",
alignItems: "center",
justifyContent: "center",
backdropFilter: "blur(8px)",
cursor: "pointer",
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
zIndex: 40,
transition: "all 200ms ease",
}}
>
<ChevronRightIcon />
</button>
{/* Pagination Dots */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", zIndex: 30 }}>
{items.map((_, idx) => (
<button
key={idx}
onClick={() => goToSlide(idx)}
aria-label={`Go to slide ${idx + 1}`}
style={{
height: "8px",
width: idx === currentIndex ? "28px" : "8px",
borderRadius: "9999px",
backgroundColor: idx === currentIndex ? "#c5a880" : "rgba(255,255,255,0.25)",
border: "none",
cursor: "pointer",
boxShadow: idx === currentIndex ? "0 0 10px rgba(197,168,128,0.7)" : "none",
transition: "all 300ms ease",
}}
/>
))}
</div>
</div>
</section>
);
}
export const Component = CoverFlowCarousel;
export default CoverFlowCarousel;
Já rodou em
- trento-pedro · home v3 — seção 4, os depoimentos (leva 1 de correções, 16/09/2026)
Exige
- react (useState, useEffect, useCallback, useRef)
Como se configura
--c-car-fundo-card--c-car-linha--c-car-tinta--c-car-raio--c-car-perspectiva--c-car-altura
A ficha
O que o 21st.dev entregou
Recebido em 16/09/2026 (noite), para substituir o carrossel de depoimentos da seção 4: "os cards de depoimentos devem passar automaticamente e com o design apresentado no código", com mais cards hipotéticos para dar volume.
Dependências: só React (useState, useEffect, useCallback, useRef); ícones SVG inline. Fotos de demonstração num CDN do 21st.dev (pratos de um restaurante indiano — o demo é de cardápio).
Como funciona:
- Palco de 520 px com
perspective: 1400px; cada card é 330 × 500 px, cantos de 18 px, borda branca a 12 %. - Posições por deslocamento em relação ao atual: centro (escala 1, opacidade 1), ±1 (
translateX(±285px) scale(.84) rotateY(∓24°), opacidade .65, brilho .75), ±2 (±510px,.68,∓38°, opacidade .38, brilho .55 + desfoque); os demais somem no centro (scale(.4), opacidade 0). Transiçãoall 800ms cubic-bezier(.25, 1, .5, 1). - Card: foto cobrindo, vinheta escura (0,4 → 0,1 → 0,68 → 0,96), conteúdo só no central (tag no canto superior direito; embaixo: título 1,65 rem/900 em caixa-alta, segunda linha, traço dourado de 34 × 2 px, descrição em itálico e um CTA pill dourado "View Menu").
- Setas redondas de 46 px em vidro escuro nas laterais; pontos de paginação (o ativo alonga para 28 px em dourado).
- Passagem automática a cada 5 s, pausada com o mouse em cima; setas do teclado (ouvinte global na janela); swipe por
touchstart/touchend(45 px). - Fundo: a foto do card atual desfocada e escurecida atrás de tudo (
brightness(.22) blur(32px)), com vinheta radial.
O que serve à Trento, e o que muda na reescrita (rodada de correções 1):
- Entra inteiro o mecanismo: palco em perspectiva, os cinco estados, as transições, setas, pontos, passagem automática, swipe e teclado — reescritos em
coverflow.js+depoimento.css, sem React. - O card vira depoimento: tag = o tipo de trabalho (
#Retrato,#Moda…), título = o nome, segunda linha = o papel, descrição = o depoimento em itálico. O CTA por card sai (um depoimento não vende) e o dourado#c5a880vira--tr-destaque-claro(nenhum hex novo). - A foto desfocada de fundo sai: feriria a regra das duas cores de fundo (§4 da especificação mobile). Os cards continuam sobre o degradê claro → escuro da seção.
- 7 cards: os 3 de sempre e 4 hipotéticos com placeholder gerado localmente (degradê escuro com a inicial e o carimbo "FOTO HIPOTÉTICA"), todos
data-placeholder="hipotetico". - Passagem automática também pausa fora da tela, com a foto ampliada aberta, com "reduzir movimento" e por 8 s depois de um toque, como os outros carrosséis da página (§10.3); o teclado só responde com a seção na tela.
- No celular o card encolhe para caber em 390 px (76 vw), mantendo a proporção 330 × 500.
O que a adaptação da OCTA mudou
- A passagem automática retoma 8 s depois de um toque, em vez de depender de hover
- O teclado só responde com a seção na tela, o que tira o conflito do ouvinte global
Lacunas do original
- A passagem automática pausa com o mouse em cima — no celular não há como pausar
- O ouvinte de setas do teclado é global na janela: rouba a seta de outro componente da mesma página
- As fotos de demonstração vêm de um CDN do 21st.dev (o demo é de cardápio de restaurante)