← AE LabBORRADOR · no está en el listado
▶
Explicado paso a paso en el episodio
5 efectos web que tus clientes no han visto nunca (el nº1 enciende la luz)
2:42De tarjeta a ficha (View Transitions)
De tarjeta a ficha con View Transitions
Pulsas una tarjeta y su portada y su título vuelan hasta la ficha, mientras la página nueva se abre como un iris desde el punto exacto donde tocaste. Al volver, el iris se cierra hacia la tarjeta.
// CÓMO ESTÁ HECHO
- Solo la tarjeta pulsada recibe view-transition-name (portada y título): el navegador los vuela de sitio.
- La página nueva se recorta con clip-path: circle() que crece desde las coordenadas del toque.
- La vieja se hunde y se desenfoca detrás; al volver se invierte con una clase en <html>.
- Sin la API, cambia al instante; con «reducir movimiento», sin animación.
// EL CÓDIGO
<main class="lista" id="lista">
<header>
<h1>Proyectos<span>_</span></h1>
<p>Pulsa una tarjeta: se convierte en su ficha.</p>
</header>
<div class="rejilla" id="rejilla"></div>
</main>
<main class="ficha" id="ficha" hidden>
<button class="volver" id="volver" type="button">← Volver</button>
<div class="ficha-cuerpo">
<div class="portada" id="fichaPortada"><b id="fichaNum"></b></div>
<div class="texto">
<span class="etiqueta" id="fichaEtiqueta"></span>
<h2 id="fichaTitulo"></h2>
<p id="fichaTexto"></p>
<div class="datos" id="fichaDatos"></div>
</div>
</div>
</main>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
--cx: 50%;
--cy: 50%;
}
* { box-sizing: border-box; margin: 0; }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-tap-highlight-color: transparent;
}
.mono { font-family: ui-monospace, "JetBrains Mono", monospace; }
/* ---------- Lista ---------- */
.lista { max-width: 1100px; margin: 0 auto; padding: 28px 18px 60px; }
.lista header { margin-bottom: 22px; }
.lista h1 { font: 800 clamp(28px, 5vw, 48px)/1.05 ui-monospace, "JetBrains Mono", monospace; letter-spacing: -.04em; }
.lista h1 span { color: var(--verde); }
.lista header p { color: var(--gris); margin-top: 8px; }
.rejilla { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.tarjeta {
appearance: none; border: 1px solid var(--borde); background: var(--superficie);
color: inherit; font: inherit; text-align: left; cursor: pointer;
border-radius: 18px; padding: 10px; display: grid; gap: 12px;
transition: transform .25s, border-color .25s;
}
.tarjeta:hover { transform: translateY(-3px); border-color: #2C4A38; }
.tarjeta:focus-visible { outline: 2px solid var(--verde); outline-offset: 3px; }
.tarjeta h2 { font-size: 17px; padding: 0 6px; }
.tarjeta p { font-size: 13px; color: var(--gris); padding: 0 6px 6px; }
/* Portadas generadas solo con CSS: cada una es un «plano» distinto */
.portada {
position: relative; aspect-ratio: 16 / 10; border-radius: 12px; overflow: hidden;
background:
radial-gradient(circle at var(--px, 30%) var(--py, 40%), rgba(0, 255, 127, .55), transparent 45%),
repeating-linear-gradient(var(--ang, 0deg), rgba(0, 255, 127, .14) 0 1px, transparent 1px 22px),
repeating-linear-gradient(calc(var(--ang, 0deg) + 90deg), rgba(0, 255, 127, .1) 0 1px, transparent 1px 22px),
#07110C;
}
.portada b {
position: absolute; left: 14px; bottom: 10px;
font: 800 42px/1 ui-monospace, "JetBrains Mono", monospace; letter-spacing: -.05em; color: var(--blanco);
}
/* ---------- Ficha ---------- */
.ficha { max-width: 1100px; margin: 0 auto; padding: 18px 18px 60px; }
.volver {
appearance: none; border: 1px solid var(--borde); background: var(--superficie); color: var(--blanco);
font: 600 14px ui-monospace, "JetBrains Mono", monospace; border-radius: 999px; padding: 10px 16px; cursor: pointer;
view-transition-name: volver;
}
.volver:focus-visible { outline: 2px solid var(--verde); outline-offset: 3px; }
.ficha-cuerpo { display: grid; gap: 22px; margin-top: 18px; }
.ficha .portada { aspect-ratio: 16 / 11; border-radius: 22px; }
.ficha .portada b { font-size: clamp(56px, 12vw, 120px); left: 22px; bottom: 16px; }
.ficha h2 { font: 800 clamp(28px, 5vw, 46px)/1.05 ui-monospace, "JetBrains Mono", monospace; letter-spacing: -.03em; }
.ficha .etiqueta { display: inline-block; color: var(--verde); font: 600 13px ui-monospace, monospace; margin-bottom: 10px; }
.ficha .texto p { color: #B9C7BF; line-height: 1.65; margin-top: 14px; max-width: 52ch; }
.ficha .datos { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
.ficha .datos span { border: 1px solid var(--borde); border-radius: 999px; padding: 6px 12px; font: 13px ui-monospace, monospace; color: var(--gris); }
@media (min-width: 820px) {
.ficha-cuerpo { grid-template-columns: 1.15fr 1fr; align-items: center; gap: 44px; }
}
[hidden] { display: none !important; }
/* ---------- La transición ----------
1) La portada y el título pulsados llevan un view-transition-name: el navegador
los «vuela» de su sitio en la tarjeta a su sitio en la ficha.
2) La página nueva se abre como un iris desde el punto exacto donde tocaste.
3) La vieja se hunde y se desenfoca detrás. */
::view-transition-group(*) { animation-duration: .62s; animation-timing-function: cubic-bezier(.7, 0, .2, 1); }
::view-transition-new(root) { animation: iris-abre .62s cubic-bezier(.7, 0, .2, 1) both; }
::view-transition-old(root) { animation: hundir .62s ease both; }
@keyframes iris-abre { from { clip-path: circle(0 at var(--cx) var(--cy)); } to { clip-path: circle(150vmax at var(--cx) var(--cy)); } }
@keyframes hundir { to { transform: scale(.94); filter: blur(6px) brightness(.45); } }
/* Al volver es al revés: la ficha se cierra hacia la tarjeta */
html.volviendo::view-transition-old(root) { z-index: 2; animation: iris-cierra .55s cubic-bezier(.7, 0, .2, 1) both; }
html.volviendo::view-transition-new(root) { animation: emerger .55s ease both; }
@keyframes iris-cierra { from { clip-path: circle(150vmax at var(--cx) var(--cy)); } to { clip-path: circle(0 at var(--cx) var(--cy)); } }
@keyframes emerger { from { transform: scale(.94); filter: blur(6px) brightness(.45); } }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
.tarjeta { transition: none; }
}
const proyectos = [
{ t: 'Carta QR para bares', e: 'hostelería', d: 'La carta en el móvil del cliente, con alérgenos y fotos. El camarero cambia un precio y se ve al momento en todas las mesas.', x: ['2 semanas', 'PHP', 'QR'], px: '25%', py: '35%', ang: '0deg' },
{ t: 'Panel de pedidos', e: 'logística', d: 'Todos los pedidos en una pantalla, por fases, con avisos cuando algo se retrasa. Menos llamadas preguntando «¿cómo va lo mío?».', x: ['4 semanas', 'SQL', 'avisos'], px: '70%', py: '30%', ang: '30deg' },
{ t: 'Reservas sin llamadas', e: 'clínicas', d: 'El paciente elige hueco desde el móvil y recibe el recordatorio. Las ausencias bajan porque nadie se olvida.', x: ['3 semanas', 'calendario', 'SMS'], px: '50%', py: '70%', ang: '60deg' },
{ t: 'Inventario con QR', e: 'almacén', d: 'Cada estantería con su código. Se escanea con el móvil y el stock cuadra solo, sin hojas de papel.', x: ['3 semanas', 'QR', 'móvil'], px: '20%', py: '75%', ang: '15deg' },
{ t: 'Asistente con IA', e: 'atención', d: 'Responde las preguntas de siempre con los datos de tu negocio y pasa a una persona lo que no sabe.', x: ['2 semanas', 'IA', 'chat'], px: '80%', py: '60%', ang: '45deg' },
{ t: 'Tienda para un taller', e: 'comercio', d: 'Piezas y servicios con precio cerrado, pago online y cita para montarlo. El taller abierto también de noche.', x: ['5 semanas', 'pagos', 'citas'], px: '45%', py: '25%', ang: '75deg' },
];
const $ = id => document.getElementById(id);
const rejilla = $('rejilla'), lista = $('lista'), ficha = $('ficha');
let abierta = -1, scrollLista = 0;
const estiloPortada = (el, p) => { el.style.setProperty('--px', p.px); el.style.setProperty('--py', p.py); el.style.setProperty('--ang', p.ang); };
proyectos.forEach((p, i) => {
const b = document.createElement('button');
b.className = 'tarjeta';
b.type = 'button';
b.innerHTML = `<div class="portada"><b>${String(i + 1).padStart(2, '0')}</b></div><h2></h2><p class="mono"></p>`;
b.querySelector('h2').textContent = p.t;
b.querySelector('p').textContent = p.e;
estiloPortada(b.querySelector('.portada'), p);
b.addEventListener('click', e => abrir(i, e));
rejilla.appendChild(b);
});
// Nombra solo los elementos de la tarjeta pulsada (dos elementos no pueden compartir nombre)
function nombrar(i, si) {
const t = rejilla.children[i];
t.querySelector('.portada').style.viewTransitionName = si ? 'portada' : '';
t.querySelector('h2').style.viewTransitionName = si ? 'titulo' : '';
}
function puntoDe(e, el) {
// Con ratón o dedo, el punto del toque; con teclado, el centro de la tarjeta
let x = e && e.clientX, y = e && e.clientY;
if (!x && !y) { const r = el.getBoundingClientRect(); x = r.left + r.width / 2; y = r.top + r.height / 2; }
document.documentElement.style.setProperty('--cx', x + 'px');
document.documentElement.style.setProperty('--cy', y + 'px');
}
function transicion(cambio) {
if (!document.startViewTransition) { cambio(); return Promise.resolve(); }
return document.startViewTransition(cambio).finished;
}
function abrir(i, e) {
const p = proyectos[i];
abierta = i;
scrollLista = scrollY;
puntoDe(e, rejilla.children[i]);
document.documentElement.classList.remove('volviendo');
nombrar(i, true);
transicion(() => {
nombrar(i, false);
$('fichaNum').textContent = String(i + 1).padStart(2, '0');
$('fichaTitulo').textContent = p.t;
$('fichaEtiqueta').textContent = '// ' + p.e;
$('fichaTexto').textContent = p.d;
$('fichaDatos').innerHTML = '';
p.x.forEach(d => { const s = document.createElement('span'); s.textContent = d; $('fichaDatos').appendChild(s); });
estiloPortada($('fichaPortada'), p);
$('fichaPortada').style.viewTransitionName = 'portada';
$('fichaTitulo').style.viewTransitionName = 'titulo';
lista.hidden = true;
ficha.hidden = false;
scrollTo(0, 0);
}).then(() => $('volver').focus({ preventScroll: true }));
}
function volver() {
if (abierta < 0) return;
const i = abierta;
document.documentElement.classList.add('volviendo');
transicion(() => {
$('fichaPortada').style.viewTransitionName = '';
$('fichaTitulo').style.viewTransitionName = '';
ficha.hidden = true;
lista.hidden = false;
scrollTo(0, scrollLista);
nombrar(i, true);
// El iris se cierra hacia el centro de la tarjeta de la que salió
const r = rejilla.children[i].getBoundingClientRect();
document.documentElement.style.setProperty('--cx', r.left + r.width / 2 + 'px');
document.documentElement.style.setProperty('--cy', r.top + r.height / 2 + 'px');
}).then(() => {
nombrar(i, false);
document.documentElement.classList.remove('volviendo');
rejilla.children[i].focus({ preventScroll: true });
abierta = -1;
});
}
$('volver').addEventListener('click', volver);
addEventListener('keydown', e => { if (e.key === 'Escape') volver(); });
▶
Explicado paso a paso en el episodio
5 efectos web que tus clientes no han visto nunca (el nº1 enciende la luz)
2:42De tarjeta a ficha (View Transitions)