← AE Lab
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
17:02La foto que es un portal
La foto que es un portal
Una galería de destinos de viaje donde cada foto es una puerta: al tocarla crece hasta llenar la pantalla y la cámara se mete dentro del paisaje, con las capas separándose en 3D. Al volver, sales del portal y la foto regresa a su tarjeta.
// CÓMO ESTÁ HECHO
- Cada paisaje se dibuja con SVG en 5 capas (cielo, lejos, medio, niebla, primer plano) generadas por código con números aleatorios con semilla.
- Dentro del portal cada capa se aleja con translateZ y se agranda con scale lo justo para verse plana: así la foto pequeña y la escena 3D coinciden.
- document.startViewTransition pasa el view-transition-name de la foto de la tarjeta a la escena: el navegador la hace crecer hasta llenar la pantalla.
- A la vez, una animación (Web Animations) avanza el mundo en Z: las capas cercanas crecen más que las lejanas y el primer plano pasa de largo.
- Sin View Transitions, un FLIP con transform hace el mismo viaje; con «reducir movimiento», un fundido; teclado completo (Enter abre, Esc vuelve).
// EL CÓDIGO
<div class="app" id="app">
<header class="cabecera">
<div>
<p class="marca"><i></i>UMBRAL · VIAJES</p>
<h1>¿A dónde te <em>llevamos</em>?</h1>
</div>
<p class="pista">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11V5a2 2 0 0 1 4 0v6m0-2a2 2 0 0 1 4 0v4a7 7 0 0 1-7 7h-.5a6 6 0 0 1-5-2.7L3 15a2 2 0 0 1 3.3-2.2L9 15"/></svg>
<span class="dedo">Toca una foto y entra en ella</span><span class="raton">Haz clic en una foto y entra en ella</span>
</p>
</header>
<main class="galeria" id="galeria" aria-label="Destinos"></main>
</div>
<div class="portal" id="portal" hidden>
<div class="oscuro" id="oscuro"></div>
<div class="escena" id="escena"><div class="camara" id="camara"><div class="mundo" id="mundo"></div></div></div>
<div class="velo"></div>
<p class="portal-marca"><i></i>UMBRAL · <span id="f-num">01 / 06</span></p>
<section class="ficha" id="ficha" role="dialog" aria-modal="true" aria-labelledby="f-nombre">
<p class="f-region" id="f-region"></p>
<h2 id="f-nombre" tabindex="-1"></h2>
<p class="f-texto" id="f-texto"></p>
<ul class="f-datos" id="f-datos"></ul>
<div class="f-botones">
<button class="reservar" id="reservar" type="button">Reservar</button>
<button id="volver" type="button">← Volver</button>
</div>
<p class="f-aviso" id="f-aviso" role="status"></p>
<p class="f-pista"><kbd>Esc</kbd> para volver</p>
</section>
</div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
}
* { 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;
}
button { font: inherit; color: inherit; cursor: pointer; }
/* ---------- Galería ---------- */
.app {
height: 100dvh; display: flex; flex-direction: column;
gap: clamp(14px, 2.2vw, 26px);
padding: clamp(16px, 3vw, 40px) clamp(16px, 4vw, 56px);
}
.cabecera { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; }
.marca { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font: 700 12px/1 ui-monospace, monospace; letter-spacing: .3em; color: var(--gris); }
.marca i { width: 8px; height: 8px; border-radius: 50%; background: var(--verde); box-shadow: 0 0 12px var(--verde); }
h1 { font-size: clamp(27px, 4.2vw, 54px); font-weight: 800; letter-spacing: -.035em; line-height: 1; }
h1 em { font-style: normal; color: var(--verde); }
.pista {
display: flex; align-items: center; gap: 10px; padding: 10px 16px;
border: 1px solid var(--borde); border-radius: 99px; background: var(--superficie); font-size: 14px;
}
.pista svg { width: 18px; height: 18px; color: var(--verde); flex: none; }
.pista .raton { display: none; }
@media (hover: hover) { .pista .dedo { display: none; } .pista .raton { display: inline; } }
.galeria {
flex: 1; min-height: 0; display: grid; gap: clamp(10px, 1.6vw, 20px);
grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr);
}
@media (max-width: 700px) {
.galeria { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(3, 1fr); }
.pista { padding: 8px 14px; font-size: 13px; }
}
/* Cada tarjeta se inclina hacia el puntero (--px, --py van de -1 a 1) */
.tarjeta {
position: relative; border: 0; padding: 0; background: none; text-align: left;
border-radius: 18px; min-height: 0;
transform: perspective(900px) rotateX(calc(var(--py, 0) * -6deg)) rotateY(calc(var(--px, 0) * 8deg));
}
.tarjeta:focus-visible { outline: 3px solid var(--verde); outline-offset: 4px; }
.foto { position: absolute; inset: 0; display: block; border-radius: inherit; overflow: hidden; background: #000; isolation: isolate; }
.capa { position: absolute; inset: 0; display: block; }
.capa svg { display: block; width: 100%; height: 100%; }
/* Paralaje en la galería: cuanto más cerca está la capa (--d), más se desplaza */
.tarjeta .capa {
transform: translate3d(calc(var(--px, 0) * var(--d) * -2.5px), calc(var(--py, 0) * var(--d) * -2px), 0) scale(1.14);
will-change: transform;
}
.rotulo {
position: absolute; left: 0; right: 0; bottom: 0; display: grid; gap: 4px;
padding: 40px 16px 14px; border-radius: 0 0 18px 18px;
background: linear-gradient(transparent, rgba(0, 0, 0, .72));
}
.rotulo b { font-size: clamp(15px, 1.5vw, 19px); letter-spacing: -.01em; }
.rotulo small { font: 12px ui-monospace, monospace; color: #c9d6cf; }
@media (max-width: 700px) { .rotulo { padding: 30px 12px 10px; } .rotulo small { font-size: 11px; } }
/* ---------- Portal (pantalla completa) ---------- */
.portal { position: fixed; inset: 0; z-index: 10; }
.portal[hidden] { display: none; }
.oscuro { position: absolute; inset: 0; background: #000; }
.escena { position: absolute; inset: 0; overflow: hidden; perspective: 800px; perspective-origin: 50% 75%; transform-origin: 0 0; }
.camara, .mundo { position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; }
.velo { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(to top, rgba(0, 0, 0, .6), transparent 55%); }
/* En vertical la foto ocupa los dos tercios de arriba y se funde con el fondo */
@media (max-aspect-ratio: 3/4) { .escena { bottom: auto; height: 66%; } .velo { background: linear-gradient(transparent 46%, #000 66%); } }
/* ① Capas con profundidad real: cada una se aleja (translateZ) y se agranda
(scale) lo justo para verse plana… hasta que la cámara avanza. */
.escena .capa {
transform: translateZ(var(--z)) scale(var(--s));
transform-origin: 50% 75%; /* el mismo punto que perspective-origin */
will-change: transform, opacity;
}
/* Ya dentro y quieto: sin will-change el navegador vuelve a pintar las capas nítidas */
.portal.nitido .capa, .portal.nitido .mundo { will-change: auto; }
.portal-marca {
position: absolute; top: clamp(16px, 3vw, 32px); left: clamp(16px, 4vw, 40px);
font: 700 12px/1 ui-monospace, monospace; letter-spacing: .3em; color: rgba(232, 245, 238, .8);
display: flex; gap: 8px; align-items: center;
}
.portal-marca i { width: 8px; height: 8px; border-radius: 50%; background: var(--verde); }
.ficha {
position: absolute; left: clamp(16px, 4vw, 48px); bottom: clamp(16px, 4vw, 48px);
width: min(420px, calc(100% - 32px)); padding: 26px;
border-radius: 22px; border: 1px solid rgba(255, 255, 255, .14);
background: rgba(5, 7, 10, .8);
display: grid; gap: 12px;
}
.f-region { font: 700 12px/1.2 ui-monospace, monospace; letter-spacing: .2em; text-transform: uppercase; color: var(--verde); }
.ficha h2 { font-size: clamp(28px, 3.2vw, 42px); font-weight: 800; letter-spacing: -.03em; line-height: 1.02; outline: none; }
.f-texto { color: #c3d1c9; font-size: 15px; line-height: 1.5; }
.f-datos { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; }
.f-datos li { padding: 6px 12px; border-radius: 99px; border: 1px solid rgba(255, 255, 255, .16); font-size: 13px; }
.f-datos b { color: var(--verde); }
.f-botones { display: flex; gap: 10px; margin-top: 4px; }
.f-botones button { border-radius: 14px; padding: 14px 20px; font-weight: 700; border: 1px solid rgba(255, 255, 255, .25); background: rgba(255, 255, 255, .04); }
.f-botones .reservar { flex: 1; background: var(--verde); color: var(--fondo); border-color: var(--verde); }
.f-botones .reservar.hecho { background: var(--blanco); border-color: var(--blanco); }
.f-botones button:focus-visible { outline: 3px solid var(--blanco); outline-offset: 3px; }
.f-aviso { font-size: 13px; color: var(--verde); min-height: 1em; }
.f-aviso:empty { display: none; }
.f-pista { font: 12px ui-monospace, monospace; color: var(--gris); }
.f-pista kbd { font: inherit; padding: 1px 6px; border: 1px solid var(--gris); border-radius: 5px; }
@media (hover: none) { .f-pista { display: none; } }
@media (max-width: 700px) {
.ficha { left: 12px; right: 12px; bottom: 12px; width: auto; padding: 20px; gap: 10px; }
.f-texto { font-size: 14px; }
}
/* ② La View Transition: la foto de la tarjeta crece hasta ser la escena.
object-fit: cover evita que se deforme al cambiar de proporción. */
::view-transition-group(portal) { animation-duration: .85s; animation-timing-function: cubic-bezier(.7, 0, .15, 1); }
::view-transition-old(portal), ::view-transition-new(portal) { height: 100%; object-fit: cover; }
::view-transition-old(portal) { animation: apagar .3s ease both; } /* cambio rápido: sin «fantasmas» */
::view-transition-new(portal) { animation: fundido .3s ease both; }
::view-transition-old(root) { animation: alejarse .85s cubic-bezier(.7, 0, .15, 1) both; }
::view-transition-new(root) { animation: fundido .85s ease both; }
html.cerrando::view-transition-old(root) { animation: fundido .6s ease reverse both; }
html.cerrando::view-transition-new(root) { animation: alejarse .85s cubic-bezier(.7, 0, .15, 1) reverse both; }
@keyframes alejarse { to { transform: scale(.92); opacity: 0; } }
@keyframes fundido { from { opacity: 0; } }
@keyframes apagar { to { opacity: 0; } }
(() => {
/* ---------- Dibujo de paisajes con SVG (nada de fotos) ---------- */
const W = 320, H = 240;
const svg = (dentro) => `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMax slice" aria-hidden="true">${dentro}</svg>`;
// Números aleatorios con semilla: el mismo paisaje sale siempre igual
const azar = (s) => () => { s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
// Una silueta (montañas si es aleatoria, dunas o copas si es suave)
function cresta(semilla, y, alto, paso, color, suave) {
const r = azar(semilla); let d = `M-20 ${H}`;
for (let x = -20; x <= W + 20; x += paso) {
const v = suave ? Math.sin(x * .02 + semilla) * .6 + Math.sin(x * .047 + semilla * 2) * .4 : r() * 2 - 1;
d += `L${x} ${(y - alto * (v * .5 + .5)).toFixed(1)}`;
}
return `<path d="${d}L${W + 20} ${H}Z" fill="${color}"/>`;
}
// Cordillera: picos y valles alternos; con «nieve», cada pico lleva su capa blanca
const f1 = (n) => n.toFixed(1);
function montes(s, y, alto, paso, color, nieve) {
const r = azar(s), pts = [];
for (let x = -20, k = 0; x <= W + 20; x += paso, k++) pts.push([x, y - alto * (k % 2 ? .55 + r() * .45 : r() * .35)]);
let o = `<path d="M-20 ${H}L${pts.map((q) => q.map(f1).join(' ')).join('L')}L${W + 20} ${H}Z" fill="${color}"/>`;
if (nieve) pts.forEach(([x, py], k) => {
if (!(k % 2) || k === pts.length - 1) return;
const [ax, ay] = pts[k - 1], [bx, by] = pts[k + 1], l = [x + (ax - x) * .3, py + (ay - py) * .3], d = [x + (bx - x) * .3, py + (by - py) * .3];
o += `<path d="M${x} ${f1(py)}L${d.map(f1).join(' ')}L${f1((x + d[0]) / 2)} ${f1((py + d[1]) / 2 + 3)}L${x} ${f1(py + (l[1] - py) * .75)}L${f1((x + l[0]) / 2)} ${f1((py + l[1]) / 2 + 4)}L${l.map(f1).join(' ')}Z" fill="${nieve}"/>`;
});
return o;
}
const pino = (x, y, h, c) => `<path d="M${x} ${y - h}l${h * .26} ${h * .55}h${-h * .1}l${h * .2} ${h * .45}h${-h * .72}l${h * .2} ${-h * .45}h${-h * .1}z" fill="${c}"/>`;
const estrellas = (n, s, ymax) => { const r = azar(s); let o = ''; for (let k = 0; k < n; k++) o += `<circle cx="${(r() * W).toFixed(1)}" cy="${(r() * ymax).toFixed(1)}" r="${(r() * .7 + .2).toFixed(2)}" fill="#fff" opacity="${(r() * .6 + .3).toFixed(2)}"/>`; return o; };
const hoja = (x, y, rx, ry, g, c) => `<g transform="translate(${x} ${y}) rotate(${g})"><ellipse rx="${rx}" ry="${ry}" fill="${c}"/><path d="M${-rx} 0H${rx}" stroke="#fff" stroke-opacity=".08"/></g>`;
const palmera = (x, y, h, inc, c) => {
const tx = x + inc, ty = y - h;
let o = `<path d="M${x} ${y}Q${x + inc * .2} ${y - h * .6} ${tx} ${ty}" stroke="${c}" stroke-width="3.5" fill="none" stroke-linecap="round"/>`;
// Cada hoja es un huso curvado (dos curvas que se juntan en la punta)
[-175, -145, -115, -65, -35, -5, 25].forEach((a) => {
const r = a * Math.PI / 180, ex = tx + Math.cos(r) * 26, ey = ty + Math.sin(r) * 8 + 12, mx = (tx + ex) / 2, my = (ty + ey) / 2;
o += `<path d="M${tx} ${ty}Q${f1(mx)} ${f1(my - 11)} ${f1(ex)} ${f1(ey)}Q${f1(mx)} ${f1(my - 4)} ${tx} ${ty}Z" fill="${c}"/>`;
});
return o;
};
const edificios = (s, y0, alto, color, luces) => {
const r = azar(s); let o = '', x = -6;
while (x < W + 6) {
const w = 14 + r() * 22, h = alto * (.45 + r() * .55);
o += `<rect x="${x.toFixed(1)}" y="${(y0 - h).toFixed(1)}" width="${w.toFixed(1)}" height="${(h + H).toFixed(1)}" fill="${color}"/>`;
for (let wy = y0 - h + 6; wy < y0 - 4; wy += 7) for (let wx = x + 3; wx < x + w - 4; wx += 5)
if (r() < luces) o += `<rect x="${wx.toFixed(1)}" y="${wy.toFixed(1)}" width="2" height="3" fill="${r() < .12 ? '#00FF7F' : '#ffd68a'}" opacity="${(r() * .5 + .4).toFixed(2)}"/>`;
x += w + 1;
}
return o;
};
// Cada destino: datos de la ficha + 5 capas de lejos a cerca (cielo, lejos, medio, niebla, primer plano)
const DESTINOS = [
{ nombre: 'Tundra de Nordlys', region: 'Norte helado', dias: 7, precio: '1.450 €', extra: 'Cabaña con chimenea',
texto: 'Cuatro noches bajo la aurora boreal, en una cabaña de madera lejos de cualquier luz.',
capas: [
{ bg: 'linear-gradient(#01040c, #04121e 50%, #0b2a33)', svg: estrellas(70, 4, 150) +
'<g transform="translate(0 34)"><path d="M-20 80Q70 20 150 64T340 40" stroke="#00FF7F" stroke-width="34" opacity=".12" fill="none"/><path d="M-20 80Q70 20 150 64T340 40" stroke="#00FF7F" stroke-width="12" opacity=".28" fill="none"/><path d="M-20 80Q70 20 150 64T340 40" stroke="#b8ffd9" stroke-width="2" opacity=".7" fill="none"/><path d="M0 110Q90 70 170 96T330 84" stroke="#3fe0ff" stroke-width="20" opacity=".12" fill="none"/></g>' },
{ svg: montes(31, 172, 100, 26, '#1c3746', '#dcf0f4') },
{ svg: (() => { const r = azar(7); let o = `<rect x="0" y="196" width="${W}" height="44" fill="#081820"/>`; for (let x = -4; x < W + 8; x += 8) o += pino(x, 200, 18 + r() * 16, '#081820');
return o + '<path d="M-20 240V218Q80 204 160 216T340 210V240Z" fill="#8eaab5"/><rect x="186" y="202" width="32" height="16" fill="#0b1418"/><path d="M182 203L202 190L222 203Z" fill="#dfeef2"/><rect x="194" y="207" width="6" height="5" fill="#ffc867"/>'; })() },
{ bg: 'linear-gradient(transparent 60%, rgba(150, 255, 210, .16) 74%, transparent 88%)' },
{ svg: pino(28, 244, 100, '#050d12') + pino(292, 244, 86, '#050d12') + pino(60, 246, 50, '#050d12') }
] },
{ nombre: 'Fiordo de Valkvik', region: 'Costa de los acantilados', dias: 8, precio: '1.290 €', extra: 'Crucero lento',
texto: 'Navega entre paredes de roca de 800 metros y duerme en una casa de madera sobre el agua.',
capas: [
{ bg: 'radial-gradient(60% 40% at 50% 72%, #ffd3a8, transparent 70%), linear-gradient(#141f3d, #3b4a7c 45%, #c9839a 72%, #f4b98f)', svg: '<ellipse cx="90" cy="60" rx="46" ry="3" fill="#f2b8a8" opacity=".35"/><ellipse cx="230" cy="44" rx="60" ry="3.5" fill="#f2b8a8" opacity=".3"/>' },
{ svg: montes(3, 170, 74, 20, '#3a4775', '#e9c6c4') + '<rect y="168" width="320" height="72" fill="#2b375f"/><rect x="138" y="174" width="44" height="1.4" fill="#f4b98f" opacity=".6"/><rect x="146" y="180" width="30" height="1.2" fill="#f4b98f" opacity=".45"/><rect x="152" y="187" width="18" height="1" fill="#f4b98f" opacity=".35"/>' },
{ svg: '<path d="M-20 240V30L30 38L70 70L105 118L128 160L142 184V240Z" fill="#1c2547"/><path d="M340 240V20L290 30L250 64L222 110L200 150L184 182V240Z" fill="#1c2547"/><path d="M158 196L164 182L164 196Z" fill="#f7e3d2"/><path d="M154 197H170L167 200H157Z" fill="#0e142c"/>' },
{ bg: 'linear-gradient(transparent 56%, rgba(255, 214, 200, .38) 69%, rgba(255, 214, 200, .08) 80%, transparent 88%)' },
{ svg: '<path d="M-20 240V212Q60 200 120 214T250 210T340 205V240Z" fill="#0a0f22"/>' + [[10, 60], [32, 84], [58, 52], [84, 72], [236, 70], [262, 90], [290, 58], [312, 80], [118, 26], [206, 32]].map(([x, h]) => pino(x, 216, h, '#0a0f22')).join('') }
] },
{ nombre: 'Isla Coralina', region: 'Mar de las tortugas', dias: 9, precio: '1.780 €', extra: 'Buceo incluido',
texto: 'Agua turquesa, arena blanca y un arrecife a diez brazadas de tu bungaló.',
capas: [
{ bg: 'radial-gradient(circle at 50% 60%, #fffbe8 0 4%, rgba(255, 240, 190, .5) 6%, transparent 25%), linear-gradient(#0d4a73, #2b9cc4 45%, #a6e8e4 72%, #ffe3ad)', svg: '<ellipse cx="70" cy="52" rx="40" ry="7" fill="#fff" opacity=".5"/><ellipse cx="96" cy="48" rx="22" ry="8" fill="#fff" opacity=".5"/><ellipse cx="250" cy="70" rx="44" ry="6" fill="#fff" opacity=".4"/>' },
{ svg: '<rect y="150" width="320" height="90" fill="#137a9c"/><path d="M20 151Q45 138 70 151Z" fill="#2a6f7a"/><path d="M250 151Q275 142 300 151Z" fill="#2a6f7a"/><rect x="150" y="156" width="20" height="1.4" fill="#fff6d0" opacity=".8"/><rect x="144" y="162" width="32" height="1.4" fill="#fff6d0" opacity=".6"/><rect x="152" y="170" width="16" height="1.2" fill="#fff6d0" opacity=".5"/>' },
{ svg: '<path d="M96 186Q160 158 226 186Z" fill="#ead39a"/><path d="M118 176Q140 164 160 172Q180 164 200 176Z" fill="#2e7d5b"/>' + palmera(146, 176, 42, -10, '#173d33') + palmera(176, 176, 34, 12, '#173d33') },
{ bg: 'linear-gradient(transparent 62%, rgba(255, 255, 255, .26) 73%, transparent 85%)' },
{ svg: '<path d="M-20 240V214Q40 204 100 214T220 212T340 210V240Z" fill="#0b4f6b"/><path d="M10 222Q40 216 70 222M180 224Q210 218 240 224" stroke="#bff3ee" stroke-opacity=".5" fill="none"/>' + hoja(10, 10, 70, 13, 30, '#0a2a22') + hoja(40, -4, 60, 11, 60, '#0d3329') + hoja(312, 12, 72, 13, 150, '#0a2a22') + hoja(286, -4, 60, 11, 120, '#0d3329') }
] },
{ nombre: 'Lumeria de noche', region: 'Ciudad que no duerme', dias: 5, precio: '980 €', extra: 'Hotel en la azotea',
texto: 'Mercados nocturnos, tranvías de neón y una piscina en la planta 40 con vistas a todo.',
capas: [
{ bg: 'radial-gradient(circle at 72% 22%, #fff7e0 0 3%, rgba(255, 240, 200, .25) 4%, transparent 14%), linear-gradient(#05061a, #1a1550 55%, #5b2370 85%, #8a3a6e)', svg: estrellas(50, 9, 120) },
{ svg: edificios(12, 172, 60, '#2b2560', .08) },
{ svg: edificios(21, 200, 130, '#120f2e', .3) + '<rect x="146" y="96" width="30" height="11" rx="2" fill="#120f2e" stroke="#00FF7F" stroke-width="1.2"/><text x="161" y="104.5" text-anchor="middle" font-family="monospace" font-size="7" fill="#00FF7F">HOTEL</text>' },
{ bg: 'linear-gradient(transparent 50%, rgba(160, 90, 220, .32) 76%, rgba(255, 120, 200, .2) 92%)' },
{ svg: '<rect x="-20" y="206" width="360" height="40" fill="#04040c"/><path d="M-20 192H340" stroke="#04040c" stroke-width="2"/>' + Array.from({ length: 28 }, (_, k) => `<rect x="${k * 12}" y="192" width="2" height="16" fill="#04040c"/>`).join('') + '<path d="M240 206V170M272 206V170M236 170H276V142Q256 130 236 142Z" stroke="#04040c" stroke-width="3" fill="#04040c"/><path d="M60 206V120M52 140H68M55 158H65" stroke="#04040c" stroke-width="2.5"/><circle cx="60" cy="118" r="2" fill="#ff4d6d"/>' }
] },
{ nombre: 'Dunas de Erg Saffa', region: 'Gran desierto rojo', dias: 6, precio: '890 €', extra: 'Noche en jaima',
texto: 'Atardeceres de fuego, cena bajo las estrellas y un amanecer desde la duna más alta.',
capas: [
{ bg: 'radial-gradient(circle at 50% 60%, #fff1c2 0 7%, #ffc56e 9%, transparent 30%), linear-gradient(#2a1236, #7d2f4f 40%, #e2703c 72%, #f7b85a)' },
{ svg: '<path d="M30 168V140L42 132H96L104 140V168Z" fill="#9c4436"/><path d="M230 168V150L240 144H272L280 152V168Z" fill="#9c4436"/>' + cresta(5, 172, 22, 6, '#b5523c', 1) },
{ svg: cresta(9, 198, 32, 6, '#8a3433', 1) },
{ bg: 'linear-gradient(transparent 55%, rgba(255, 190, 120, .32) 68%, transparent 82%)' },
{ svg: cresta(13, 226, 28, 6, '#3d1420', 1) + '<path d="M58 236V170M58 202H46V184M58 192H70V170M262 236V182M262 210H252V196M262 200H272V186" stroke="#3d1420" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>' }
] },
{ nombre: 'Selva de Verdeluz', region: 'Río de las cascadas', dias: 10, precio: '1.540 €', extra: 'Guía naturalista',
texto: 'Diez días entre cascadas, puentes colgantes y el canto de mil pájaros al amanecer.',
capas: [
{ bg: 'radial-gradient(50% 40% at 50% 30%, rgba(230, 255, 200, .7), transparent 70%), linear-gradient(#0f3a33, #3c8a68 55%, #b9e6b0)', svg: '<path d="M150 0L120 240H150Z" fill="#fff" opacity=".07"/><path d="M175 0L200 240H230Z" fill="#fff" opacity=".06"/>' },
{ svg: cresta(21, 150, 40, 8, '#2f6b57', 1) + (() => { let o = ''; for (let x = -10; x < W + 10; x += 13) o += `<circle cx="${x}" cy="${(150 - 40 * ((Math.sin(x * .02 + 21) * .6 + Math.sin(x * .047 + 42) * .4) * .5 + .5) + 4).toFixed(1)}" r="9" fill="#2f6b57"/>`; return o; })() },
{ svg: '<path d="M-20 240V110Q60 95 124 120L136 240Z" fill="#1f4a3c"/><path d="M340 240V100Q260 92 192 118L182 240Z" fill="#1f4a3c"/><path d="M120 118H196V126H120Z" fill="#163a2f"/><rect x="138" y="124" width="42" height="116" fill="#dff5ec" opacity=".85"/><path d="M146 126V240M156 126V240M168 126V240" stroke="#9fd8c4" stroke-width="1.2" opacity=".7"/>' + [10, 34, 60, 88, 112, 200, 226, 254, 282, 310].map((x, k) => `<circle cx="${x}" cy="${(k < 5 ? 104 : 100) + Math.abs(x - 160) * .05}" r="16" fill="#1a4032"/>`).join('') },
{ bg: 'radial-gradient(40% 30% at 50% 88%, rgba(240, 255, 250, .75), transparent 70%), linear-gradient(transparent 60%, rgba(220, 255, 235, .22) 80%, transparent)' },
{ svg: hoja(20, 230, 64, 15, -30, '#0b241b') + hoja(60, 236, 52, 12, -60, '#103326') + hoja(300, 228, 66, 15, 210, '#0b241b') + hoja(262, 238, 50, 12, 240, '#103326') + hoja(4, 20, 60, 12, 40, '#0b241b') + hoja(160, 246, 40, 10, -90, '#103326') + hoja(140, 246, 36, 9, -60, '#0b241b') + hoja(180, 246, 36, 9, -120, '#0b241b') }
] }
];
/* ---------- Montaje de la galería ---------- */
const P = 800; // distancia de la cámara (perspective)
const Z = [-1800, -900, -380, -160, 220]; // profundidad de cada capa
const ZOOM = 1.14; // margen para que el paralaje no enseñe bordes
const VIAJE = 250; // cuánto avanza la cámara al entrar
const $ = (id) => document.getElementById(id);
const raiz = document.documentElement, galeria = $('galeria'), portal = $('portal'), escena = $('escena'),
camara = $('camara'), mundo = $('mundo'), ficha = $('ficha'), app = $('app');
const QUIETO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const CON_VT = !!document.startViewTransition && !location.search.includes('sinvt');
const capas = (d) => d.capas.map((c, k) =>
`<span class="capa" style="--d:${k};--z:${Z[k]}px;--s:${((P - Z[k]) / P * ZOOM).toFixed(4)};background:${c.bg || 'none'}">${c.svg ? svg(c.svg) : ''}</span>`).join('');
DESTINOS.forEach((d, i) => {
const b = document.createElement('button');
b.className = 'tarjeta'; b.type = 'button';
b.setAttribute('aria-label', `${d.nombre}: ${d.dias} días, desde ${d.precio}. Abrir`);
b.innerHTML = `<span class="foto">${capas(d)}</span><span class="rotulo"><b>${d.nombre}</b><small>${d.dias} días · desde ${d.precio}</small></span>`;
b.addEventListener('click', () => abrir(i));
galeria.appendChild(b);
});
const tarjetas = [...galeria.children];
function rellenarPortal(i) {
const d = DESTINOS[i];
mundo.innerHTML = capas(d);
mundo.style.transform = ''; portal.classList.remove('nitido');
$('f-num').textContent = `${String(i + 1).padStart(2, '0')} / ${String(DESTINOS.length).padStart(2, '0')}`;
$('f-region').textContent = d.region;
$('f-nombre').textContent = d.nombre;
$('f-texto').textContent = d.texto;
$('f-datos').innerHTML = `<li><b>${d.dias}</b> días</li><li>desde <b>${d.precio}</b></li><li>${d.extra}</li>`;
$('reservar').textContent = 'Reservar'; $('reservar').classList.remove('hecho'); $('f-aviso').textContent = '';
}
const delante = () => mundo.lastElementChild;
const limpiar = () => [mundo, delante(), ficha, escena, $('oscuro'), portal].forEach((el) => el && el.getAnimations().forEach((a) => a.cancel()));
/* ③ La cámara atraviesa la foto: el mundo avanza en Z; las capas cercanas
crecen más que las lejanas (paralaje real) y el primer plano pasa de largo. */
function entrar() {
const ir = { duration: 1800, easing: 'cubic-bezier(.35, 0, .1, 1)', fill: 'forwards' };
const viaje = mundo.animate([{ transform: 'translateZ(0)' }, { transform: `translateZ(${VIAJE}px)` }], ir);
delante().animate([{ opacity: 1 }, { opacity: 1, offset: .35 }, { opacity: 0 }], ir);
const f = ficha.animate([{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }],
{ duration: 600, delay: 1100, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'both' });
return Promise.all([viaje.finished, f.finished]);
}
// Al llegar, la posición final pasa al estilo y se quitan las animaciones (y el will-change)
function fijar() {
mundo.style.transform = `translateZ(${VIAJE}px)`; delante().style.opacity = 0;
[mundo, delante()].forEach((el) => el.getAnimations().forEach((a) => a.cancel()));
portal.classList.add('nitido');
}
/* ④ Respaldo sin View Transitions (FLIP): la escena empieza encima de la
tarjeta (translate + scale) y se anima hasta ocupar toda la pantalla. */
function flip(foto, abrir) {
const a = foto.getBoundingClientRect(), b = escena.getBoundingClientRect();
const tarjeta = `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${a.width / b.width}, ${a.height / b.height})`;
const pasos = [{ transform: tarjeta }, { transform: 'none' }];
const t = { duration: 850, easing: 'cubic-bezier(.7, 0, .15, 1)', fill: 'both' };
$('oscuro').animate([{ opacity: 0 }, { opacity: 1 }], { ...t, direction: abrir ? 'normal' : 'reverse' });
return escena.animate(abrir ? pasos : pasos.reverse(), t).finished;
}
const fundir = (abrir) => portal.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 400, fill: 'both', direction: abrir ? 'normal' : 'reverse' }).finished;
/* ---------- Abrir y cerrar el portal ---------- */
let abierto = -1, ocupado = false, demo = true;
// Si el usuario pide algo en mitad de una animación, se apunta y se hace al acabar
let pendiente = null;
function alAcabar() {
const p = pendiente; pendiente = null;
if (p === 'cerrar') cerrar();
else if (p !== null) (abierto === p ? Promise.resolve() : abierto >= 0 ? cerrar(true) : Promise.resolve()).then(() => abrir(p));
}
async function abrir(i, porDemo) {
if (ocupado) { if (!porDemo) pendiente = i; return; }
if (abierto === i && !porDemo) return $('f-nombre').focus({ preventScroll: true });
if (abierto >= 0) return;
ocupado = true; abierto = i;
const foto = tarjetas[i].querySelector('.foto');
quieta(i); // la foto sale recta, sin inclinación
rellenarPortal(i);
const mostrar = () => { portal.hidden = false; app.inert = true; };
if (QUIETO) { ficha.style.opacity = 1; mostrar(); await fundir(true); }
else if (CON_VT) {
// ② Abrir: el nombre «portal» pasa de la foto de la tarjeta a la escena
foto.style.viewTransitionName = 'portal';
const vt = document.startViewTransition(() => {
foto.style.viewTransitionName = '';
escena.style.viewTransitionName = 'portal';
mostrar(); entrada = entrar();
});
await vt.finished; await entrada;
escena.style.viewTransitionName = '';
} else { mostrar(); flip(foto, true); await entrar(); }
if (!QUIETO) fijar();
ocupado = false;
if (!porDemo) $('f-nombre').focus({ preventScroll: true });
alAcabar();
}
let entrada;
async function cerrar(porDemo) {
if (ocupado) { if (!porDemo) pendiente = 'cerrar'; return; }
if (abierto < 0) return;
ocupado = true;
const i = abierto, foto = tarjetas[i].querySelector('.foto');
const ocultar = () => { portal.hidden = true; app.inert = false; limpiar(); };
if (QUIETO) { await fundir(false); ocultar(); }
else {
// La ficha se va y la cámara retrocede hasta el marco de la foto
const atras = { duration: 800, easing: 'cubic-bezier(.6, 0, .3, 1)', fill: 'forwards' };
portal.classList.remove('nitido');
ficha.animate([{ opacity: 1 }, { opacity: 0, transform: 'translateY(16px)' }], { duration: 250, fill: 'forwards' });
delante().animate([{ opacity: 0 }, { opacity: 1 }], atras);
await mundo.animate([{ transform: `translateZ(${VIAJE}px)` }, { transform: 'translateZ(0)' }], atras).finished;
if (CON_VT) {
escena.style.viewTransitionName = 'portal';
raiz.classList.add('cerrando');
const vt = document.startViewTransition(() => {
escena.style.viewTransitionName = '';
foto.style.viewTransitionName = 'portal';
ocultar();
});
await vt.finished;
foto.style.viewTransitionName = ''; raiz.classList.remove('cerrando');
} else { await flip(foto, false); ocultar(); }
}
abierto = -1; ocupado = false;
if (!porDemo) tarjetas[i].focus({ preventScroll: true });
alAcabar();
}
$('volver').addEventListener('click', () => cerrar());
$('reservar').addEventListener('click', () => {
$('reservar').textContent = '✓ Plaza apartada'; $('reservar').classList.add('hecho');
$('f-aviso').textContent = 'Te escribimos en menos de 24 h para confirmarla.';
});
addEventListener('keydown', (e) => { if (e.key === 'Escape') cerrar(); });
/* ---------- Paralaje de la galería (puntero, inclinación o demo) ---------- */
const mira = tarjetas.map(() => ({ x: 0, y: 0 }));
let puntero = null, inclinacion = null;
const tope = (v) => Math.max(-1, Math.min(1, v));
tarjetas.forEach((t, i) => {
t.addEventListener('pointermove', (e) => {
const r = t.getBoundingClientRect();
puntero = { i, x: tope((e.clientX - r.left) / r.width * 2 - 1), y: tope((e.clientY - r.top) / r.height * 2 - 1) };
});
t.addEventListener('pointerleave', () => { puntero = null; });
});
addEventListener('deviceorientation', (e) => { if (e.gamma != null) inclinacion = { x: tope(e.gamma / 25), y: tope((e.beta - 45) / 25) }; });
function quieta(i) { mira[i] = { x: 0, y: 0 }; tarjetas[i].style.setProperty('--px', 0); tarjetas[i].style.setProperty('--py', 0); }
let camX = 0, camY = 0;
function bucle(t) {
if (!QUIETO && abierto < 0 && !ocupado) {
tarjetas.forEach((el, i) => {
let x = 0, y = 0;
if (puntero && puntero.i === i) ({ x, y } = puntero);
else if (inclinacion) ({ x, y } = inclinacion);
else if (demo) { x = Math.sin(t / 900 + i * 1.3) * .8; y = Math.cos(t / 1100 + i) * .5; }
const m = mira[i];
m.x += (x - m.x) * .08; m.y += (y - m.y) * .08;
el.style.setProperty('--px', m.x.toFixed(3)); el.style.setProperty('--py', m.y.toFixed(3));
});
}
// Dentro del portal, la cámara se mece con el puntero (o sola en la demo)
if (!QUIETO && abierto >= 0) {
const p = ocupado ? { x: 0, y: 0 } : puntero || inclinacion || { x: Math.sin(t / 1300) * .7, y: Math.cos(t / 1700) * .4 };
camX += (p.x - camX) * .05; camY += (p.y - camY) * .05;
camara.style.transform = `translate3d(${(-camX * 40).toFixed(2)}px, ${(-camY * 24).toFixed(2)}px, 0)`;
}
requestAnimationFrame(bucle);
}
requestAnimationFrame(bucle);
portal.addEventListener('pointermove', (e) => { puntero = { i: -1, x: tope(e.clientX / innerWidth * 2 - 1), y: tope(e.clientY / innerHeight * 2 - 1) }; });
/* ⑤ Modo demostración: entra en un portal, mira 3 s, sale y entra en el
siguiente. Cualquier toque, clic o tecla lo detiene. */
let reloj;
function pasoDemo(i) {
if (!demo) return;
abrir(i, true).then(() => { if (demo) reloj = setTimeout(() => cerrar(true).then(() => {
if (demo) reloj = setTimeout(() => pasoDemo((i + 1) % DESTINOS.length), 600);
}), 3000); });
}
const pararDemo = () => { demo = false; clearTimeout(reloj); };
['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => addEventListener(t, pararDemo, { capture: true, passive: true }));
reloj = setTimeout(() => pasoDemo(0), 350);
})();
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
17:02La foto que es un portal