← AE Lab
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
8:38Carrusel en 3D que gira como un cilindro
Carrusel en 3D que gira como un cilindro
El catálogo de una bodega colocado en un anillo 3D que giras con el dedo o el ratón: lleva inercia, frena suave y encaja en la botella más cercana, que se ilumina y enseña sus detalles. Las de detrás se ven por el revés, difuminadas.
// CÓMO ESTÁ HECHO
- Cada tarjeta se coloca con rotateY(i·360/N) translateZ(radio), y el radio sale de (ancho/2)/tan(π/N) para que cierren un polígono perfecto.
- El anillo (transform-style: preserve-3d) es lo único que gira; se inclina con rotateX para que la fila de atrás asome por encima.
- Cada tarjeta tiene dos caras con backface-visibility: hidden; la trasera está girada 180° y lleva un desenfoque falso con degradados y text-shadow.
- Al soltar se calcula la velocidad del gesto, se proyecta dónde se pararía y un muelle amortiguado lo encaja en la tarjeta más cercana.
- En cada fotograma se mide cuánto mira cada tarjeta hacia el frente y con eso se cambia solo la opacidad de su luz, su sombra y sus detalles.
// EL CÓDIGO
<div class="app">
<header class="cabecera">
<span class="marca mono">Bodega Nueve Lunas</span>
<h1>Nuestros <span>vinos</span></h1>
<p>Gira la bodega y descubre cada botella</p>
</header>
<!-- La escena es un carrusel accesible: se maneja con el teclado y anuncia la botella activa -->
<section class="escena" id="escena" tabindex="0"
role="region" aria-roledescription="carrusel" aria-label="Catálogo de vinos">
<div class="suelo" aria-hidden="true"></div>
<div class="anillo" id="anillo"></div>
</section>
<footer class="pie">
<div class="controles">
<button class="btn" id="anterior" type="button" aria-label="Vino anterior">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
</button>
<span class="contador" id="contador"><b>01</b> / 08</span>
<button class="btn" id="siguiente" type="button" aria-label="Vino siguiente">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</button>
</div>
<span class="pista">Arrastra para girar · <kbd>←</kbd> <kbd>→</kbd></span>
<p class="solo-lectores" id="anuncio" aria-live="polite"></p>
</footer>
</div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
/* Tamaño de cada tarjeta: el radio del anillo se calcula a partir de este ancho */
--ancho: min(clamp(184px, 58vw, 290px), 42dvh);
--inclina: -15deg; /* inclinación del cilindro: deja ver la fila de atrás por encima */
--alto: calc(var(--ancho) * 1.5);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
.mono { font-family: ui-monospace, "JetBrains Mono", Consolas, monospace; }
.app {
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
padding: calc(18px + env(safe-area-inset-top)) 16px calc(18px + env(safe-area-inset-bottom));
/* Un halo verde muy suave detrás del anillo */
background: radial-gradient(60% 45% at 50% 52%, rgba(0, 255, 127, .07), transparent 70%);
}
/* ---------- Cabecera ---------- */
.cabecera { text-align: center; display: grid; gap: 6px; }
.cabecera .marca { font-size: 12px; letter-spacing: .28em; text-transform: uppercase; color: var(--verde); }
.cabecera h1 { font: 800 clamp(26px, 6vw, 44px)/1.05 ui-monospace, "JetBrains Mono", monospace; letter-spacing: -.04em; }
.cabecera h1 span { color: var(--verde); }
.cabecera p { color: var(--gris); font-size: 14px; }
/* ---------- Escena 3D ---------- */
.escena {
position: relative;
overflow: hidden;
display: grid; place-items: center;
touch-action: none; /* el arrastre lo gestionamos nosotros */
cursor: grab;
user-select: none;
outline: none;
margin: 0 -16px; /* a sangre: el cilindro llega al borde de la pantalla */
/* la perspectiva la ajusta el JS según el radio */
perspective: 1000px;
}
.escena.arrastrando { cursor: grabbing; }
.escena:focus-visible { box-shadow: inset 0 0 0 2px var(--verde); }
/* Suelo: una elipse de luz bajo el anillo */
.suelo {
position: absolute; left: 50%; top: 50%;
width: min(760px, 120%); height: 120px;
transform: translate(-50%, calc(var(--alto) / 2 + 10px));
background: radial-gradient(closest-side, rgba(0, 255, 127, .16), transparent);
pointer-events: none;
}
/* El anillo: un contenedor 3D que solo gira (transform) */
.anillo {
position: relative;
width: var(--ancho); height: var(--alto);
transform-style: preserve-3d;
will-change: transform;
/* la fila de atrás asoma por arriba: subimos un poco el conjunto para centrarlo */
margin-bottom: calc(var(--alto) * .3);
}
/* Cada tarjeta se coloca con rotateY(ángulo) translateZ(radio) desde el JS */
.tarjeta {
position: absolute; inset: 0;
transform-style: preserve-3d;
}
.cara, .dorso {
position: absolute; inset: 0;
border-radius: 20px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
overflow: hidden;
}
/* --- Cara delantera --- */
.cara {
background:
radial-gradient(120% 70% at 50% 0%, color-mix(in srgb, var(--tono) 38%, transparent), transparent 70%),
var(--superficie);
border: 1px solid var(--borde);
display: grid;
grid-template-rows: 47% 1fr;
}
.arte { position: relative; overflow: hidden; }
/* Luna de fondo: un círculo con un hueco que la convierte en creciente */
.luna {
position: absolute; top: 14%; left: 50%;
width: 52%; aspect-ratio: 1; border-radius: 50%;
transform: translateX(-50%);
background: radial-gradient(circle at 35% 35%, color-mix(in srgb, var(--tono) 60%, #fff), var(--tono));
opacity: .35;
-webkit-mask: radial-gradient(circle at calc(50% + var(--fase)) 42%, transparent 46%, #000 47%);
mask: radial-gradient(circle at calc(50% + var(--fase)) 42%, transparent 46%, #000 47%);
}
/* Botella dibujada con CSS: cuerpo + cuello (::before) + etiqueta */
.botella {
position: absolute; bottom: -6%; left: 50%;
width: 26%; height: 74%;
transform: translateX(-50%);
border-radius: 34% 34% 10px 10px / 22% 22% 10px 10px;
background: linear-gradient(90deg, rgba(0,0,0,.55), transparent 35%, rgba(255,255,255,.18) 48%, transparent 60%, rgba(0,0,0,.6)), var(--vidrio);
}
.botella::before {
content: ""; position: absolute; left: 50%; bottom: 96%;
width: 34%; height: 38%;
transform: translateX(-50%);
border-radius: 4px 4px 0 0;
background: linear-gradient(90deg, rgba(0,0,0,.5), rgba(255,255,255,.15) 50%, rgba(0,0,0,.5)), var(--vidrio);
border-top: 6px solid var(--capsula);
}
.botella .etiqueta {
position: absolute; left: 8%; right: 8%; top: 34%; height: 34%;
border-radius: 3px;
background: #F2EBDD;
display: grid; place-items: center;
}
.etiqueta i {
width: 42%; aspect-ratio: 1; border-radius: 50%;
box-shadow: inset -4px 2px 0 0 var(--tono);
}
.texto { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 4px; min-height: 0; }
.tipo { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--gris); }
.texto h3 { font-size: clamp(17px, 4.6vw, 21px); line-height: 1.1; letter-spacing: -.02em; }
/* Los detalles solo aparecen en la tarjeta de delante (opacidad + desplazamiento desde el JS) */
.detalles { display: grid; gap: 3px; font-size: 12.5px; line-height: 1.35; color: var(--gris); margin-top: auto; border-top: 1px solid var(--borde); padding-top: 8px; }
.detalles b { color: var(--blanco); font-weight: 600; }
.precio { display: flex; align-items: baseline; justify-content: space-between; margin-top: 4px; }
.precio strong { font: 700 18px ui-monospace, Consolas, monospace; color: var(--verde); }
.precio small { font-size: 11px; color: var(--gris); }
/* Capas de luz y sombra: solo cambian de opacidad (barato a 60 fps) */
.luz, .sombra { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; }
.luz {
border: 1.5px solid var(--verde);
box-shadow: 0 0 34px rgba(0, 255, 127, .35), inset 0 0 40px rgba(0, 255, 127, .12);
opacity: 0;
}
.sombra { background: #05070A; opacity: .6; }
/* --- Cara trasera: el revés, difuminado (desenfoque falso con degradados y text-shadow) --- */
.dorso {
transform: rotateY(180deg);
background:
radial-gradient(50% 35% at 50% 32%, color-mix(in srgb, var(--tono) 45%, transparent), transparent),
radial-gradient(70% 40% at 50% 85%, rgba(0, 255, 127, .08), transparent),
#0A100D;
border: 1px solid rgba(27, 42, 33, .7);
opacity: .75;
display: grid; place-items: center;
}
.dorso span {
color: transparent;
text-shadow: 0 0 8px rgba(232, 245, 238, .7);
font: 800 22px ui-monospace, monospace;
transform: scaleX(-1); /* se lee al revés, como mirar la tarjeta por detrás */
letter-spacing: -.02em;
}
/* ---------- Controles ---------- */
.pie { display: grid; justify-items: center; gap: 10px; }
.controles { display: flex; align-items: center; gap: 14px; }
.btn {
width: 48px; height: 48px; border-radius: 50%;
border: 1px solid var(--borde); background: var(--superficie); color: var(--blanco);
display: grid; place-items: center; cursor: pointer;
transition: border-color .2s, transform .2s;
}
.btn:hover { border-color: var(--verde); }
.btn:active { transform: scale(.94); }
.btn:focus-visible { outline: 2px solid var(--verde); outline-offset: 3px; }
.btn svg { width: 20px; height: 20px; }
.contador { font: 600 14px ui-monospace, Consolas, monospace; color: var(--gris); min-width: 9ch; text-align: center; }
.contador b { color: var(--verde); }
.pista { font-size: 12px; color: var(--gris); }
.pista kbd { font: 11px ui-monospace, monospace; border: 1px solid var(--borde); border-radius: 4px; padding: 1px 5px; color: var(--blanco); }
.solo-lectores { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-height: 700px) {
.cabecera p { display: none; }
}
/* ================= 1. Los datos del catálogo (inventados) ================= */
const vinos = [
{ nombre: 'Luna Nueva', tipo: 'Blanco · 2025', uva: 'Verdejo', notas: 'cítricos e hinojo', con: 'ostras, arroz meloso', precio: '14,50 €', tono: '#D9C46A', vidrio: '#3E4A2A', capsula: '#C9B458', fase: '28%' },
{ nombre: 'Cuarto Creciente', tipo: 'Rosado · 2025', uva: 'Garnacha', notas: 'fresa y pétalos', con: 'sushi, ensaladas', precio: '12,90 €', tono: '#F08CA0', vidrio: '#5A2F38', capsula: '#E07A8E', fase: '18%' },
{ nombre: 'Media Luna', tipo: 'Tinto joven · 2024', uva: 'Tempranillo', notas: 'mora y violeta', con: 'embutidos, pasta', precio: '11,00 €', tono: '#B23A5A', vidrio: '#2A0E16', capsula: '#7A1E36', fase: '8%' },
{ nombre: 'Luna de Barrica', tipo: 'Crianza · 2021', uva: 'Tempranillo, Graciano',notas: 'vainilla y cacao', con: 'cordero asado', precio: '22,00 €', tono: '#9B2D3C', vidrio: '#22090E', capsula: '#B8873B', fase: '-6%' },
{ nombre: 'Eclipse', tipo: 'Reserva · 2018', uva: 'Tempranillo', notas: 'cuero y regaliz', con: 'carnes de caza', precio: '38,00 €', tono: '#6E1F2E', vidrio: '#160609', capsula: '#1A1A1A', fase: '0%' },
{ nombre: 'Marea Alta', tipo: 'Espumoso · 2022', uva: 'Viura, Chardonnay', notas: 'pan tostado', con: 'mariscos, fritos', precio: '19,50 €', tono: '#E8E0B0', vidrio: '#1E2A22', capsula: '#D4C27A', fase: '-18%'},
{ nombre: 'Luna de Miel', tipo: 'Dulce · 2020', uva: 'Moscatel', notas: 'miel y orejones', con: 'quesos azules', precio: '17,00 €', tono: '#E3A23B', vidrio: '#4A2A0C', capsula: '#E3A23B', fase: '-28%'},
{ nombre: 'Luna Llena', tipo: 'Tinto roble · 2023', uva: 'Monastrell', notas: 'ciruela y especias', con: 'arroces de monte', precio: '15,80 €', tono: '#C2405E', vidrio: '#2C0B14', capsula: '#C2405E', fase: '60%' },
{ nombre: 'Niebla', tipo: 'Blanco barrica · 2023', uva: 'Godello', notas: 'melocotón y humo', con: 'bacalao, setas', precio: '24,00 €', tono: '#BFD08A', vidrio: '#2C361E', capsula: '#9FB06A', fase: '-40%'},
{ nombre: 'Cara Oculta', tipo: 'Edición limitada', uva: 'Garnacha vieja', notas: 'fruta negra, grafito',con: 'quedan 120 botellas', precio: '64,00 €', tono: '#00FF7F', vidrio: '#0B1A12', capsula: '#00C866', fase: '40%' },
];
const escena = document.getElementById('escena');
const anillo = document.getElementById('anillo');
const contador = document.getElementById('contador');
const anuncio = document.getElementById('anuncio');
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const N = vinos.length;
const PASO = 360 / N; // grados entre tarjeta y tarjeta
/* ================= 2. Construir las tarjetas ================= */
const tarjetas = vinos.map((v, i) => {
const el = document.createElement('article');
el.className = 'tarjeta';
el.dataset.i = i;
el.style.cssText = `--tono:${v.tono};--vidrio:${v.vidrio};--capsula:${v.capsula};--fase:${v.fase}`;
el.setAttribute('aria-label', `${v.nombre}, ${v.tipo}`);
el.innerHTML = `
<div class="cara">
<div class="arte" aria-hidden="true">
<div class="luna"></div>
<div class="botella"><div class="etiqueta"><i></i></div></div>
</div>
<div class="texto">
<p class="tipo">${v.tipo}</p>
<h3>${v.nombre}</h3>
<div class="detalles">
<span><b>${v.uva}</b> · ${v.notas}</span>
<span>Con ${v.con}</span>
<span class="precio"><strong>${v.precio}</strong><small>botella 75 cl</small></span>
</div>
</div>
<div class="luz"></div>
<div class="sombra"></div>
</div>
<div class="dorso" aria-hidden="true"><span>${v.nombre}</span></div>`;
anillo.appendChild(el);
return {
el,
luz: el.querySelector('.luz'),
sombra: el.querySelector('.sombra'),
detalles: el.querySelector('.detalles'),
};
});
/* ================= 3. Geometría: radio del cilindro =================
Para que N tarjetas de ancho w formen un polígono cerrado,
el radio es (w/2) / tan(π/N). Le sumamos un pequeño hueco. */
let radio = 300;
function medir() {
const w = anillo.offsetWidth;
radio = Math.round((w / 2) / Math.tan(Math.PI / N) + w * 0.08);
escena.style.perspective = `${Math.round(radio * 2.3)}px`;
tarjetas.forEach((t, i) => {
t.el.style.transform = `rotateY(${i * PASO}deg) translateZ(${radio}px)`;
});
pintar();
}
/* ================= 4. Estado del giro =================
angulo: rotación actual del anillo (grados)
vel: velocidad (grados/segundo)
objetivo: si no es null, un muelle lleva el ángulo hasta ahí */
let angulo = 0, vel = 0, objetivo = 0;
let activa = -1;
const indiceDe = a => ((Math.round(-a / PASO) % N) + N) % N;
function pintar() {
// El anillo se empuja hacia atrás para que la tarjeta de delante quede en z = 0,
// y se inclina un poco para que se lea como un cilindro.
anillo.style.transform = `translateZ(${-radio}px) rotateX(var(--inclina)) rotateY(${angulo}deg)`;
// Cuánto mira cada tarjeta hacia nosotros: 1 de frente, 0 de lado
tarjetas.forEach((t, i) => {
let d = ((i * PASO + angulo) % 360 + 540) % 360 - 180; // -180..180
const frente = Math.max(0, 1 - Math.abs(d) / (PASO * 0.9));
t.luz.style.opacity = frente.toFixed(3);
t.sombra.style.opacity = (0.62 * (1 - frente)).toFixed(3);
t.detalles.style.opacity = frente.toFixed(3);
t.detalles.style.transform = `translateY(${((1 - frente) * 10).toFixed(1)}px)`;
});
const i = indiceDe(angulo);
if (i !== activa) {
activa = i;
contador.innerHTML = `<b>${String(i + 1).padStart(2, '0')}</b> / ${String(N).padStart(2, '0')}`;
}
}
/* ================= 5. El bucle de física ================= */
let ultimo = performance.now();
let arrastrando = false;
function bucle(ahora) {
const dt = Math.min((ahora - ultimo) / 1000, 1 / 30);
ultimo = ahora;
if (demo.activo) demoPaso(dt);
if (!arrastrando && objetivo !== null) {
// Muelle amortiguado: fuerza hacia el objetivo menos rozamiento.
// Con este rozamiento rebota un pelín al encajar, como un mecanismo real.
const rigidez = 70, rozamiento = 13;
vel += ((objetivo - angulo) * rigidez - vel * rozamiento) * dt;
angulo += vel * dt;
if (Math.abs(objetivo - angulo) < 0.01 && Math.abs(vel) < 0.05) { angulo = objetivo; vel = 0; }
}
pintar();
requestAnimationFrame(bucle);
}
// Ir a una tarjeta por la ruta más corta
function irA(i) {
const actual = Math.round(-angulo / PASO);
let salto = ((i - actual) % N + N) % N;
if (salto > N / 2) salto -= N;
mover(salto);
}
// Mover n tarjetas a partir de la que está encajada (o hacia la que va)
function mover(n) {
const base = objetivo !== null ? objetivo : Math.round(angulo / PASO) * PASO;
objetivo = base - n * PASO;
if (quieto) { angulo = objetivo; vel = 0; }
anunciar(indiceDe(objetivo));
}
function anunciar(i) {
const v = vinos[i];
anuncio.textContent = `${v.nombre}, ${v.tipo}. ${v.uva}, ${v.notas}. ${v.precio}. ${i + 1} de ${N}.`;
}
/* ================= 6. Arrastre con ratón o dedo + inercia ================= */
let inicioX = 0, inicioAng = 0, movido = 0, muestras = [];
escena.addEventListener('pointerdown', e => {
usuarioToca();
arrastrando = true;
escena.classList.add('arrastrando');
escena.setPointerCapture(e.pointerId);
inicioX = e.clientX; inicioAng = angulo; movido = 0;
objetivo = null; vel = 0;
muestras = [{ x: e.clientX, t: performance.now() }];
});
escena.addEventListener('pointermove', e => {
if (!arrastrando) return;
const dx = e.clientX - inicioX;
movido = Math.max(movido, Math.abs(dx));
// Grados por píxel = los que recorre la superficie del cilindro bajo el dedo
angulo = inicioAng + dx * (180 / (Math.PI * radio));
muestras.push({ x: e.clientX, t: performance.now() });
if (muestras.length > 6) muestras.shift();
});
function soltar(e) {
if (!arrastrando) return;
arrastrando = false;
escena.classList.remove('arrastrando');
// Un toque sin arrastre sobre una tarjeta lateral la trae al frente
if (movido < 6) {
const t = e.target.closest && e.target.closest('.tarjeta');
if (t) irA(+t.dataset.i); else mover(0);
return;
}
// Velocidad de lanzamiento a partir de los últimos movimientos
const a = muestras[0], b = muestras[muestras.length - 1];
const pxs = (b.x - a.x) / Math.max((b.t - a.t) / 1000, 0.016);
vel = pxs * (180 / (Math.PI * radio));
if (quieto) vel = 0;
// Dónde se pararía por inercia… y se encaja en la tarjeta más cercana a ese punto
const destino = angulo + vel * 0.32;
objetivo = Math.round(destino / PASO) * PASO;
if (quieto) angulo = objetivo;
anunciar(indiceDe(objetivo));
}
escena.addEventListener('pointerup', soltar);
escena.addEventListener('pointercancel', soltar);
// Rueda o trackpad: una tarjeta por gesto (como mucho una cada 350 ms)
let ultimaRueda = 0;
escena.addEventListener('wheel', e => {
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
e.preventDefault();
if (Math.abs(d) < 4 || performance.now() - ultimaRueda < 350) return;
ultimaRueda = performance.now();
usuarioToca();
mover(d > 0 ? 1 : -1);
}, { passive: false });
/* ================= 7. Teclado y botones ================= */
escena.addEventListener('keydown', e => {
const mapa = { ArrowRight: 1, ArrowLeft: -1, Home: null, End: null };
if (!(e.key in mapa)) return;
e.preventDefault();
usuarioToca();
if (e.key === 'Home') irA(0);
else if (e.key === 'End') irA(N - 1);
else mover(mapa[e.key]);
});
document.getElementById('siguiente').addEventListener('click', () => { usuarioToca(); mover(1); });
document.getElementById('anterior').addEventListener('click', () => { usuarioToca(); mover(-1); });
/* ================= 8. Modo demostración =================
Gira despacio solo y, cada pocos segundos, da un «tirón» hasta la
siguiente botella y se queda un momento enseñándola. En cuanto
alguien toca, se para; vuelve tras un rato sin actividad. */
const demo = { activo: true, fase: 'deriva', t: 0 };
let reanudar;
function demoPaso(dt) {
demo.t += dt;
if (quieto) {
// Sin movimiento: cambia de botella de golpe cada 4 s
if (demo.t > 4) { demo.t = 0; mover(1); }
return;
}
if (demo.fase === 'deriva') {
objetivo = null;
vel += (-9 - vel) * Math.min(1, dt * 3); // acelera suave hasta 9°/s
angulo += vel * dt;
if (demo.t > 1.8) {
// El tirón: salta a la siguiente tarjeta (a veces dos) con un muelle
const saltos = Math.random() < 0.25 ? 2 : 1;
objetivo = (Math.floor(angulo / PASO) - (saltos - 1)) * PASO;
if (objetivo > angulo - PASO * 0.35) objetivo -= PASO;
vel -= 120; // golpe inicial
demo.fase = 'tiron'; demo.t = 0;
}
} else if (demo.fase === 'tiron' && demo.t > 2.4) {
demo.fase = 'deriva'; demo.t = 0;
}
}
function usuarioToca() {
if (demo.activo) {
demo.activo = false;
if (objetivo === null) objetivo = Math.round(angulo / PASO) * PASO;
}
clearTimeout(reanudar);
reanudar = setTimeout(() => { demo.activo = true; demo.fase = 'tiron'; demo.t = 0; }, 12000);
}
/* ================= Arranque ================= */
addEventListener('resize', medir);
medir();
anunciar(0);
requestAnimationFrame(t => { ultimo = t; requestAnimationFrame(bucle); });
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
8:38Carrusel en 3D que gira como un cilindro