← AE Lab
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
3:22Modo oscuro con eclipse
Modo oscuro con eclipse
Al pulsar el sol, la luna lo eclipsa y la noche se derrama por toda la página en un círculo que nace del botón. La web de una cafetería pasa de desayunos a coctelería con textos, precios y colores distintos.
// CÓMO ESTÁ HECHO
- Dos juegos de variables CSS (claro y oscuro) cuelgan de un atributo data-tema en <html>, que un script en el <head> fija antes de pintar: tema guardado en localStorage o, si no hay, prefers-color-scheme.
- El sol y la luna son un único SVG: un disco oscuro recortado con clipPath cruza el sol con @keyframes de transform, se para en el centro (eclipse total con halo de gradiente radial) y se queda desplazado formando la media luna.
- En el eclipse, document.startViewTransition cambia el tema y Element.animate recorta ::view-transition-new(root) con un clip-path circle() que crece desde el centro del botón hasta la esquina más lejana (Math.hypot).
- Sin View Transitions, un disco del color nuevo crece con transform: scale y se desvanece; con prefers-reduced-motion solo hay un fundido.
- Un modo demostración alterna el tema cada 3,4 s sin guardarlo y se apaga con el primer toque, tecla o scroll.
// EL CÓDIGO
<!-- El astro: un único dibujo que se reutiliza (sol de día, luna de noche) -->
<template id="plantilla-astro">
<svg viewBox="-50 -50 100 100" aria-hidden="true">
<defs>
<radialGradient id="ID-halo">
<stop offset="45%" style="stop-color:var(--astro);stop-opacity:0"/>
<stop offset="52%" style="stop-color:#FFE7B0"/>
<stop offset="64%" style="stop-color:var(--astro);stop-opacity:.45"/>
<stop offset="100%" style="stop-color:var(--astro);stop-opacity:0"/>
</radialGradient>
<clipPath id="ID-recorte"><circle r="23"/></clipPath>
</defs>
<g class="giro">
<g class="rayos">
<line x1="0" y1="-31" x2="0" y2="-42"/><line x1="0" y1="31" x2="0" y2="42"/>
<line x1="-31" y1="0" x2="-42" y2="0"/><line x1="31" y1="0" x2="42" y2="0"/>
<line x1="22" y1="-22" x2="30" y2="-30"/><line x1="-22" y1="22" x2="-30" y2="30"/>
<line x1="-22" y1="-22" x2="-30" y2="-30"/><line x1="22" y1="22" x2="30" y2="30"/>
</g>
</g>
<circle class="corona" r="44" fill="url(#ID-halo)"/>
<circle class="astro" r="22"/>
<!-- La luna se recorta al disco + un poco: fuera del astro no se ve -->
<g clip-path="url(#ID-recorte)"><circle class="luna" r="22"/></g>
</svg>
</template>
<div class="app" id="app">
<header class="cabecera">
<div class="marca"><i></i>Penumbra</div>
<nav class="nav" aria-label="Principal">
<a href="#carta">Carta</a><a href="#horario">Horario</a><a href="#reservas">Reservas</a>
</nav>
<button class="interruptor" id="interruptor" type="button" aria-pressed="false" aria-label="Activar modo oscuro"></button>
</header>
<main class="portada">
<div class="cielo" id="cielo">
<svg class="estrellas" viewBox="0 0 100 100" aria-hidden="true" style="position:absolute;inset:-20%;width:140%;height:140%">
<circle class="estrella" cx="8" cy="20" r=".8"/><circle class="estrella" cx="92" cy="14" r=".6"/>
<circle class="estrella" cx="80" cy="88" r=".9"/><circle class="estrella" cx="14" cy="78" r=".6"/>
<circle class="estrella" cx="50" cy="4" r=".5"/><circle class="estrella" cx="96" cy="56" r=".7"/>
<circle class="estrella" cx="3" cy="50" r=".5"/><circle class="estrella" cx="30" cy="96" r=".7"/>
</svg>
</div>
<div class="texto">
<p class="etiqueta" id="horario">
<span data-dia>Abierto · 8:00 – 17:00</span><span data-noche>Abierto · 19:00 – 01:00</span>
</p>
<h1>
<span data-dia>Café lento<br>bajo el <em>sol</em>.</span>
<span data-noche>Cócteles<br>bajo la <em>luna</em>.</span>
</h1>
<p class="bajada">
<span data-dia>Tostamos cada mañana y servimos desayunos sin prisa en la terraza.</span>
<span data-noche>Cuando se pone el sol, la barra se vuelve coctelería con vinilos.</span>
</p>
<a class="cta" href="#reservas" id="reservas">Reservar mesa</a>
</div>
</main>
<section class="carta" id="carta" aria-label="Carta destacada">
<div class="plato"><span class="ico"><span data-dia>☕</span><span data-noche>🍸</span></span><span><b data-dia>Flat white</b><b data-noche>Espresso martini</b><small data-dia>Leche de avena</small><small data-noche>Café, vainilla, hielo</small></span><span><span data-dia>3,20 €</span><span data-noche>9,50 €</span></span></div>
<div class="plato"><span class="ico"><span data-dia>🥐</span><span data-noche>🧀</span></span><span><b data-dia>Croissant de mantequilla</b><b data-noche>Tabla de quesos</b><small data-dia>Recién horneado</small><small data-noche>Tres curaciones</small></span><span><span data-dia>2,40 €</span><span data-noche>14,00 €</span></span></div>
<div class="plato"><span class="ico"><span data-dia>🍊</span><span data-noche>🍹</span></span><span><b data-dia>Zumo de naranja</b><b data-noche>Negroni de la casa</b><small data-dia>Exprimido al momento</small><small data-noche>Con piel de naranja</small></span><span><span data-dia>3,80 €</span><span data-noche>10,00 €</span></span></div>
</section>
</div>
<p class="aviso-demo" id="aviso">MODO DEMOSTRACIÓN · PRUEBA EL BOTÓN DE ARRIBA</p>
/* ── Paleta: dos juegos de variables, uno por tema ── */
:root {
--fondo: #F4EEE3;
--superficie: #FFFFFF;
--borde: #E4D9C6;
--texto: #1C1813;
--gris: #7A6E5E;
--acento: #E07B1F;
--astro: #F3A93B;
--sombra-luna: #05070A;
color-scheme: light;
}
[data-tema="oscuro"] {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--texto: #E8F5EE;
--gris: #7C8A82;
--acento: #00FF7F;
--astro: #E8F5EE;
color-scheme: dark;
}
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; }
body {
background: var(--fondo);
color: var(--texto);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* Textos que cambian con el tema: el día y la noche del local */
[data-tema="oscuro"] [data-dia],
[data-tema="claro"] [data-noche] { display: none; }
.app {
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
overflow-y: auto;
overflow-x: hidden;
}
/* ── Cabecera ── */
.cabecera {
display: flex; align-items: center; gap: 24px;
padding: 14px clamp(16px, 4vw, 48px);
border-bottom: 1px solid var(--borde);
}
.marca { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 18px; }
.marca i { width: 26px; height: 26px; border-radius: 50%; background: var(--acento); box-shadow: inset -7px -3px 0 var(--fondo); }
.nav { display: none; gap: 26px; margin-left: auto; color: var(--gris); font-size: 14px; }
.nav a { color: inherit; text-decoration: none; }
.nav a:hover { color: var(--texto); }
/* ── El interruptor sol/luna ── */
.interruptor {
margin-left: auto;
appearance: none; border: 1px solid var(--borde); background: var(--superficie);
width: 52px; height: 52px; padding: 6px; border-radius: 50%;
cursor: pointer; color: var(--texto);
display: grid; place-items: center;
flex: none;
}
.interruptor svg { width: 100%; height: 100%; overflow: visible; }
.interruptor:focus-visible { outline: 3px solid var(--acento); outline-offset: 3px; }
@media (min-width: 760px) {
.nav { display: flex; }
.interruptor { margin-left: 8px; }
}
/* ── Las piezas del astro (el mismo SVG en el botón y en la portada) ──
· .rayos: los rayos del sol
· .astro: el disco (ámbar de día, pálido de noche)
· .luna: un disco oscuro que pasa por delante. De noche se queda
desplazado y deja ver una media luna.
· .corona: el halo que brilla justo en el eclipse total */
.astro { fill: var(--astro); transition: fill .6s; }
.rayos { stroke: var(--astro); stroke-width: 4; stroke-linecap: round; transform-origin: 0 0; transition: transform .6s cubic-bezier(.3, 1.4, .5, 1), opacity .4s, stroke .6s; }
.luna { fill: var(--sombra-luna); transform: translate(64px, -64px); }
.corona { opacity: 0; transform-origin: 0 0; }
[data-tema="oscuro"] .rayos { transform: scale(.4) rotate(45deg); opacity: 0; }
[data-tema="oscuro"] .luna { transform: translate(11px, -9px); }
/* El eclipse: la luna cruza el sol, se para en el centro y sigue */
.a-noche .luna { animation: luna-a-noche 1.3s cubic-bezier(.45, 0, .25, 1) both; }
.a-dia .luna { animation: luna-a-dia 1.3s cubic-bezier(.45, 0, .25, 1) both; }
.a-noche .corona,
.a-dia .corona { animation: corona 1.3s ease-out both; }
@keyframes luna-a-noche {
0% { transform: translate(64px, -64px); }
38% { transform: translate(0, 0); }
58% { transform: translate(0, 0); }
100% { transform: translate(11px, -9px); }
}
@keyframes luna-a-dia {
0% { transform: translate(11px, -9px); }
30% { transform: translate(0, 0); }
50% { transform: translate(0, 0); }
100% { transform: translate(-64px, 64px); }
}
@keyframes corona {
0%, 25% { opacity: 0; transform: scale(.8); }
45% { opacity: 1; transform: scale(1.12); }
100% { opacity: 0; transform: scale(1.35); }
}
/* ── Portada ── */
.portada {
display: grid; align-content: center; gap: clamp(12px, 3vh, 32px);
padding: clamp(14px, 3vh, 40px) clamp(16px, 4vw, 48px);
}
.cielo { position: relative; width: min(46vw, 30vh, 420px); aspect-ratio: 1; margin: 0 auto; }
.cielo svg { width: 100%; height: 100%; overflow: visible; }
/* Rayos que giran despacio y estrellas que titilan: la página respira */
.cielo .giro { animation: girar 40s linear infinite; transform-origin: 0 0; }
@keyframes girar { to { transform: rotate(360deg); } }
.estrella { fill: var(--texto); opacity: 0; transition: opacity .6s; }
[data-tema="oscuro"] .estrella { animation: titilar 3s ease-in-out infinite; }
.estrella:nth-child(2n) { animation-delay: -1.1s !important; }
.estrella:nth-child(3n) { animation-delay: -2.2s !important; }
@keyframes titilar { 0%, 100% { opacity: .15; } 50% { opacity: .9; } }
.texto { text-align: center; }
.etiqueta {
display: inline-flex; align-items: center; gap: 8px;
font: 600 12px/1 ui-monospace, "JetBrains Mono", monospace;
letter-spacing: .08em; text-transform: uppercase; color: var(--gris);
border: 1px solid var(--borde); border-radius: 99px; padding: 8px 12px;
}
.etiqueta::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--acento); }
h1 {
margin-top: 14px;
font-size: clamp(32px, 6.4vw, 76px); line-height: 1; letter-spacing: -.045em; font-weight: 800;
}
h1 em { font-style: normal; color: var(--acento); }
.bajada { margin: 12px auto 0; max-width: 36ch; color: var(--gris); line-height: 1.5; font-size: clamp(15px, 1.3vw, 18px); }
.cta {
display: inline-block; margin-top: 18px;
background: var(--texto); color: var(--fondo);
padding: 13px 22px; border-radius: 99px; text-decoration: none; font-weight: 700; font-size: 15px;
}
.cta:focus-visible { outline: 3px solid var(--acento); outline-offset: 3px; }
/* ── Carta: tres platos, cambian del día a la noche ── */
.carta {
display: grid; gap: 8px;
padding: 0 clamp(16px, 4vw, 48px) calc(38px + env(safe-area-inset-bottom));
}
.plato {
display: flex; align-items: center; gap: 12px;
background: var(--superficie); border: 1px solid var(--borde); border-radius: 16px;
padding: 12px 14px;
}
.plato b { font-size: 15px; }
.plato small { display: block; color: var(--gris); font-size: 13px; margin-top: 2px; }
.plato > span:last-child { margin-left: auto; font: 700 14px ui-monospace, "JetBrains Mono", monospace; color: var(--acento); }
.plato .ico { width: 38px; height: 38px; border-radius: 12px; background: var(--fondo); display: grid; place-items: center; font-size: 18px; flex: none; }
.aviso-demo {
position: fixed; left: 50%; bottom: calc(10px + env(safe-area-inset-bottom)); transform: translateX(-50%);
font: 600 11px ui-monospace, monospace; letter-spacing: .06em; color: var(--gris);
pointer-events: none; transition: opacity .4s; white-space: nowrap;
}
.aviso-demo[hidden] { display: block; opacity: 0; }
@media (min-width: 900px) {
.portada { grid-template-columns: 1.1fr 1fr; align-items: center; }
.texto { text-align: left; order: -1; }
.bajada { margin-left: 0; }
.cielo { width: min(38vw, 58vh, 520px); }
.carta { grid-template-columns: repeat(3, 1fr); gap: 14px; padding-bottom: 44px; }
.plato { padding: 16px 18px; }
}
/* ── View Transitions: quitamos el fundido por defecto;
el círculo lo animamos nosotros desde JS ── */
::view-transition-old(root),
::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
/* Plan B sin View Transitions: un círculo con el color nuevo que crece
desde el botón (solo transform y opacity) */
.ola {
position: fixed; z-index: 50; border-radius: 50%; pointer-events: none;
transform: scale(0); will-change: transform, opacity;
}
/* Movimiento reducido: sin eclipse ni círculo, solo un fundido suave */
@media (prefers-reduced-motion: reduce) {
.a-noche .luna, .a-dia .luna, .a-noche .corona, .a-dia .corona,
.cielo .giro, [data-tema="oscuro"] .estrella { animation: none; }
[data-tema="oscuro"] .estrella { opacity: .6; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .4s; }
}
/* Antes de pintar nada: tema guardado o, si no hay, el del sistema.
Así no hay «parpadeo» de tema al cargar. */
(function () {
let guardado = null;
try { guardado = localStorage.getItem('eclipse-tema'); } catch (e) {}
const oscuroSistema = matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.tema = guardado || (oscuroSistema ? 'oscuro' : 'claro');
})();
const raiz = document.documentElement;
const boton = document.getElementById('interruptor');
const aviso = document.getElementById('aviso');
const sinMovimiento = matchMedia('(prefers-reduced-motion: reduce)').matches;
const CLAVE = 'eclipse-tema';
/* 1 · Pintamos el astro en el botón y en la portada con la misma plantilla
(cambiando los id internos para que no choquen) */
function ponerAstro(destino, sufijo) {
const html = document.getElementById('plantilla-astro').innerHTML.replaceAll('ID-', sufijo + '-');
destino.insertAdjacentHTML('beforeend', html);
}
ponerAstro(boton, 'b');
ponerAstro(document.getElementById('cielo'), 'p');
/* 2 · Reflejar el tema en el botón (lector de pantalla incluido) */
function sincronizarBoton() {
const oscuro = raiz.dataset.tema === 'oscuro';
boton.setAttribute('aria-pressed', String(oscuro));
boton.setAttribute('aria-label', oscuro ? 'Activar modo claro' : 'Activar modo oscuro');
}
sincronizarBoton();
function aplicarTema(tema) {
raiz.dataset.tema = tema;
sincronizarBoton();
}
/* 3 · El cambio de tema con eclipse + círculo que se expande */
let ocupado = false;
function cambiarTema({ guardar }) {
if (ocupado) return;
ocupado = true;
const nuevo = raiz.dataset.tema === 'oscuro' ? 'claro' : 'oscuro';
if (guardar) { try { localStorage.setItem(CLAVE, nuevo); } catch (e) {} }
// Movimiento reducido: cambio directo con un fundido suave
if (sinMovimiento) {
if (document.startViewTransition) document.startViewTransition(() => aplicarTema(nuevo));
else aplicarTema(nuevo);
ocupado = false;
return;
}
// Centro del botón y radio hasta la esquina más lejana de la pantalla
const r = boton.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const radio = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
// a) La luna empieza a cruzar el sol (en el botón y en la portada)
raiz.classList.remove('a-noche', 'a-dia');
void raiz.offsetWidth; // reinicia la animación si se repite
raiz.classList.add(nuevo === 'oscuro' ? 'a-noche' : 'a-dia');
// b) En el eclipse total, el tema nuevo se expande desde el botón
setTimeout(() => {
if (document.startViewTransition) expandirConViewTransition(nuevo, x, y, radio);
else expandirConOla(nuevo, x, y, radio);
}, 430);
setTimeout(() => { raiz.classList.remove('a-noche', 'a-dia'); ocupado = false; }, 1500);
}
// Camino moderno: el navegador hace una foto del antes y del después,
// y nosotros recortamos el «después» con un círculo que crece.
function expandirConViewTransition(nuevo, x, y, radio) {
const transicion = document.startViewTransition(() => aplicarTema(nuevo));
transicion.ready.then(() => {
raiz.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radio}px at ${x}px ${y}px)`] },
{ duration: 950, easing: 'cubic-bezier(.65, 0, .35, 1)', pseudoElement: '::view-transition-new(root)' }
);
}).catch(() => {});
}
// Plan B: un disco del color del tema nuevo crece con transform: scale,
// cambiamos el tema por debajo y el disco se desvanece.
function expandirConOla(nuevo, x, y, radio) {
const ola = document.createElement('div');
ola.className = 'ola';
ola.style.cssText = `left:${x - radio}px; top:${y - radio}px; width:${radio * 2}px; height:${radio * 2}px;
background:${nuevo === 'oscuro' ? '#05070A' : '#F4EEE3'}`;
document.body.appendChild(ola);
const crecer = ola.animate([{ transform: 'scale(0)' }, { transform: 'scale(1)' }],
{ duration: 800, easing: 'cubic-bezier(.65, 0, .35, 1)', fill: 'forwards' });
crecer.onfinish = () => {
aplicarTema(nuevo);
ola.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 350, fill: 'forwards' })
.onfinish = () => ola.remove();
};
}
boton.addEventListener('click', () => { pararDemo(); cambiarTema({ guardar: true }); });
/* 4 · Si el usuario no ha elegido, seguimos al sistema en vivo */
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
let guardado = null;
try { guardado = localStorage.getItem(CLAVE); } catch (err) {}
if (!guardado) aplicarTema(e.matches ? 'oscuro' : 'claro');
});
/* 5 · Modo demostración: alterna solo cada pocos segundos (sin guardar)
y se detiene en cuanto alguien toca, pulsa una tecla o hace scroll */
let reloj = setTimeout(function paso() {
cambiarTema({ guardar: false });
reloj = setTimeout(paso, 3400);
}, 900);
function pararDemo() {
if (!reloj) return;
clearTimeout(reloj);
reloj = null;
aviso.hidden = true;
}
['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((tipo) =>
addEventListener(tipo, pararDemo, { once: true, passive: true }));
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
3:22Modo oscuro con eclipse