
Celosía
ProObjetos 3D · three.js
La pieza
Una gota de aceite de oliva virgen extra flota en un estudio procedural sobre curvas de nivel de la montaña. Es cristal líquido de verdad: refracta el fondo con el índice del aceite (1,47), brilla a contraluz con un halo dorado y proyecta una cáustica. Respira con ruido orgánico, flota y gira despacio; el cursor la hunde donde la toca y le arranca ondas que recorren la superficie, y un clic la hace bambolear como gelatina. En móvil responde al toque y hace ondas sola. Al hacer scroll cruza la pantalla hasta una ficha de cata con barras segmentadas. Un conmutador Noche/Día pasa del negro oliva al crema con transición suave. Encaja en almazaras, tiendas gourmet, restaurantes, cosmética natural o cualquier portada que quiera un objeto hipnótico y táctil. Cambia el color del aceite a verde, miel o ámbar con una variable.
Encaja bien en webs de
Antes de copiar
Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.
Gratis y entero
Qué trae el prompt, por apartados
Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.
# Recrea en un único archivo HTML: Gota de aceite de oliva
Una gota de aceite de oliva virgen extra, dorada y translúcida, flota en un estudio oscuro sobre curvas de nivel de la montaña, respira con ruido orgánico y ondula como un líquido espeso cuando la tocas.
Eres un desarrollador front-end creativo senior. Genera un único `index.html` autocontenido que
reproduzca exactamente la pieza descrita: mismo layout, textos, colores, movimiento e interacción.
HTML, CSS y JS en un solo archivo, sin build ni frameworks. Librerías solo por CDN y en las versiones
indicadas. Todos los valores numéricos de este documento son definitivos: úsalos tal cual.
Responde solo con el archivo completo.
## Qué es
Una escena 3D de portada para una almazara ficticia, **Alcor** (aceite de oliva virgen extra de Arvale, Valle Alto). Es una página de dos pantallas:
1. **Portada** (`#inicio`, 100svh). A la izquierda, abajo, el bloque de texto: antetítulo en mono dorado, titular enorme en serif suave de dos líneas (la segunda en cursiva dorada), entradilla, dos botones y el precio. A la derecha, centrada verticalmente, **la gota**: una esfera de radio 1 deformada en el vertex shader con ruido simplex, con `MeshPhysicalMaterial` de transmisión (índice de refracción 1,47, el del aceite de oliva), atenuación dorada, clearcoat y dispersión. Detrás, un fondo de estudio hecho con un shader: curvas de nivel procedurales (las lomas de la montaña), un halo dorado muy intenso justo detrás de la gota (contraluz: por eso la gota se ve luminosa y no marrón), una cáustica dorada desplazada abajo a la derecha y, en el tema claro, una sombra suave. Las curvas de nivel se ven **refractadas y aumentadas dentro de la gota**; dentro del halo se dibujan oscuras para que la refracción se note. Una etiqueta técnica en mono («Fig. 01 — Una gota, 0,05 ml») con una línea fina acompaña a la gota por arriba a la izquierda. Abajo, una barra HUD: «Desliza · ficha de cata» a la izquierda y tres datos + botón «Tocar la gota» a la derecha.
2. **Ficha de cata** (`#ficha`, 100svh). Al hacer scroll la gota viaja de la derecha a la izquierda (y gira), y a la derecha aparece la ficha: antetítulo, titular «Verde de hoja, / final de almendra.», texto, cuatro barras segmentadas (frutado, amargo, picante, dulzor), una rejilla de seis datos y el contacto. Pie de página abajo.
Arriba, fija, la cabecera con la marca y un **conmutador Noche / Día** que cambia el fondo del estudio de casi negro oliva a crema cálido, con transición suave de todos los colores (también los del shader y del material de la gota).
El cursor **empuja** la gota: donde está el puntero la superficie se hunde (con un ligero abombamiento alrededor) y, al entrar o moverse sobre ella, nacen **ondas** que recorren la superficie y se amortiguan. Un clic o toque hace una onda y un bamboleo elástico (se estira en vertical y se estrecha). Si nadie la toca, cada 3,2 s hace una onda sola. En móvil la gota viaja con la portada, sale por arriba y **reaparece al final de la página**, flotando en un hueco reservado encima del pie.
## Librerías y esqueleto
- three.js **0.186.1** por import map (`three` y `three/addons/`), jsDelivr. Solo se usa `RoomEnvironment` de los addons.
- Nada más: ni GSAP ni Lenis. Todo el movimiento de la escena va en un bucle `requestAnimationFrame` propio; el de la interfaz, en CSS.
- Fuentes de Google (todas con ñ, tildes, ¿ y ¡): **Fraunces** (display, variable; se cargan los ejes `ital`, `opsz`, `wght` y `SOFT` y se usa con `"opsz" 144, "SOFT" 100`), **Geist** (texto) y **Geist Mono** (etiquetas y datos).
Cabecera del documento, literal (el `<style>` completo va en las dos secciones siguientes, en este orden: primero los tokens y luego el resto):
```html
<!doctype html>
<html lang="es" data-tema="noche">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Alcor · Gota de aceite de oliva</title>
<meta name="description" content="Una gota de aceite de oliva virgen extra, dorada y translúcida, que flota y ondula al tocarla.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@0,9..144,300..600,0..100;1,9..144,300..600,0..100&family=Geist:wght@400;500&family=Geist+Mono:wght@400;500&display=swap">
<style>
/* aquí van los tokens y el CSS completo de las dos secciones siguientes */
</style>
</head>
```
Import map y apertura del módulo, justo antes de `</body>`:
```html
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"}}
</script>
<script type="module">
/* módulo completo: ver «Código clave» */
</script>
</body>
</html>
```
## Tokens de diseño
Bloque literal. El tema activo lo marca `data-tema="noche"|"dia"` en `<html>` (por defecto `noche`). `--oil`, `--bg-noche` y `--bg-dia` los lee también el JS para colorear la escena 3D: son la única fuente de verdad de esos tres colores.
```css
:root{
--oil:#d9a521;
--bg-noche:#11120c;
--bg-dia:#eee5d1;
--ease-out:cubic-bezier(.19,1,.22,1);
--ease-soft:cubic-bezier(.65,0,.35,1);
--gutter:clamp(20px,4vw,56px);
--f-display:"Fraunces",Georgia,serif;
--f-text:"Geist",system-ui,sans-serif;
--f-mono:"Geist Mono",ui-monospace,monospace;
}
:root[data-tema="noche"]{--bg:var(--bg-noche);--ink:#efe7d4;--muted:#a9a38d;--line:rgb(239 231 212/.16);--accent:#e6b94a;--btn-bg:var(--oil);--btn-ink:#17150b}
:root[data-tema="dia"]{--bg:var(--bg-dia);--ink:#1c1a10;--muted:#595440;--line:rgb(28 26 16/.18);--accent:#7a5800;--btn-bg:#1c1a10;--btn-ink:#f6efdf}
```
Resumen de uso:
| Token | Noche | Día | Uso |
|---|---|---|---|
| `--bg` | `#11120c` | `#eee5d1` | fondo de página y del estudio 3D |
| `--ink` | `#efe7d4` | `#1c1a10` | texto principal |
| `--muted` | `#a9a38d` | `#595440` | entradillas, etiquetas, datos |
| `--line` | crema al 16 % | tinta al 18 % | filetes, bordes, pistas de barras |
| `--accent` | `#e6b94a` | `#7a5800` | antetítulos, cursivas del titular, barras |
| `--btn-bg` / `--btn-ink` | `#d9a521` / `#17150b` | `#1c1a10` / `#f6efdf` | botón principal |
Easings: `--ease-out: cubic-bezier(.19,1,.22,1)` (entradas y controles) y `--ease-soft: cubic-bezier(.65,0,.35,1)` (cambios de tema). Margen lateral: `--gutter: clamp(20px,4vw,56px)`.
Titulares: Fraunces peso 340, `letter-spacing:-.035em`, `line-height:.94`; h1 `clamp(3rem,1.2rem + 5.6vw,7.4rem)`, h2 `clamp(2.4rem,1.2rem + 3.4vw,4.6rem)`. Las cursivas (`em`) usan la itálica de Fraunces con los mismos ejes y van en `--accent`. Etiquetas en Geist Mono 0,66–0,72rem, mayúsculas, `letter-spacing` .1–.16em.
## Estructura y contenido
### Árbol
```
body
├─ canvas#escena (fixed, 100 % × 100 %, z 0, aria-hidden) ← la escena three.js
├─ div.gota-css (aria-hidden) ← gota en CSS, solo sin WebGL
├─ header.top (fixed, z 3) marca + conmutador Noche/Día
├─ main (z 1)
│ ├─ section.hero#inicio texto abajo-izquierda + .hud abajo
│ └─ section.ficha#ficha rejilla 12 col.; .ficha-in en col. 7–12; .hueco (solo móvil); footer.pie
└─ p.etiqueta (fixed, aria-hidden) la mueve el JS junto a la gota
```
### HTML literal del `<body>` (hasta antes del import map)
Todos los textos son definitivos. El `href` de `tel:` es `+00 000 000 000` **sin espacios**.
```html
<body>
<canvas id="escena" aria-hidden="true"></canvas>
<div class="gota-css" aria-hidden="true"></div>
<header class="top">
<a class="marca" href="#inicio"><span class="marca-n">Alcor</span><span class="marca-s">Almazara · Arvale</span></a>
<button class="tema" type="button" aria-pressed="false"><span class="sr">Fondo claro</span><span class="tema-o" aria-hidden="true">Noche</span><span class="tema-o" aria-hidden="true">Día</span></button>
</header>
<main>
<section class="hero" id="inicio" aria-labelledby="titulo">
<div class="hero-txt">
<p class="eyebrow entra" style="--d:.1s">Virgen extra · Cosecha temprana 2026</p>
<h1 id="titulo"><span class="l"><span style="--d:.18s">Cae despacio,</span></span><span class="l"><em style="--d:.28s">sabe a monte.</em></span></h1>
<p class="lead entra" style="--d:.5s">Aceite de oliva virgen extra de coratina y koroneiki, molido en frío en las lomas de Arvale antes de que amanezca. Espeso, verde y dorado, como tiene que ser.</p>
<div class="acciones entra" style="--d:.62s">
<a class="btn" href="mailto:hola@alcor.com?subject=Reserva%20cosecha%202026">Reservar la cosecha <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.4"/></svg></a>
<a class="btn-sec" href="#ficha">Ver ficha de cata</a>
</div>
<p class="precio entra" style="--d:.72s">Lata de 500 ml · <strong>18,90 €</strong> impuestos incl.</p>
</div>
<div class="hud entra" style="--d:.9s">
<a class="desliza" href="#ficha"><span aria-hidden="true"></span>Desliza · ficha de cata</a>
<ul class="hud-d">
<li><b>1,47</b>índice de refracción</li>
<li><b>0,18°</b>acidez</li>
<li><b>21 °C</b>molturación</li>
<li><button class="tocar" type="button">Tocar la gota</button></li>
</ul>
</div>
</section>
<section class="ficha" id="ficha" aria-labelledby="ficha-t">
<div class="ficha-in">
<p class="eyebrow">Ficha de cata · N.º 01</p>
<h2 id="ficha-t">Verde de hoja,<br><em>final de almendra.</em></h2>
<p class="lead">Entra suave, a hierba recién cortada y a tomatera. Amarga lo justo en el centro de la lengua y pica al final, en la garganta: la señal de un aceite vivo.</p>
<dl class="perfil">
<div><dt>Frutado</dt><dd class="barra" style="--v:8;--i:0" aria-hidden="true"><i></i></dd><dd>8/10</dd></div>
<div><dt>Amargo</dt><dd class="barra" style="--v:4;--i:1" aria-hidden="true"><i></i></dd><dd>4/10</dd></div>
<div><dt>Picante</dt><dd class="barra" style="--v:5;--i:2" aria-hidden="true"><i></i></dd><dd>5/10</dd></div>
<div><dt>Dulzor</dt><dd class="barra" style="--v:6;--i:3" aria-hidden="true"><i></i></dd><dd>6/10</dd></div>
</dl>
<dl class="datos">
<div><dt>Variedad</dt><dd>Coratina y koroneiki</dd></div>
<div><dt>Recogida</dt><dd>Del 12 al 28 de octubre</dd></div>
<div><dt>Acidez</dt><dd>0,18°</dd></div>
<div><dt>Molturación</dt><dd>En frío, a 21 °C</dd></div>
<div><dt>Origen</dt><dd>Colinas de Arvale, Valle Alto</dd></div>
<div><dt>Formato</dt><dd>Lata de 500 ml · 18,90 € impuestos incl.</dd></div>
</dl>
<div class="contacto">
<a class="btn" href="mailto:hola@alcor.com?subject=Muestra%20cosecha%202026">Pedir una muestra</a>
<a href="tel:+00 000 000 000">+00 000 000 000</a>
<a href="mailto:hola@alcor.com">hola@alcor.com</a>
</div>
</div>
<div class="hueco" aria-hidden="true"></div>
<footer class="pie"><span>© 2026 Alcor · Almazara familiar en Arvale, Valle Alto</span><span>Una gota pesa 0,05 ml. Esta, un poco más.</span></footer>
</section>
</main>
<p class="etiqueta" aria-hidden="true">Fig. 01 — Una gota, 0,05 ml</p>
```
### CSS completo (va dentro del mismo `<style>`, a continuación de los tokens)
```css
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{background:var(--bg);color:var(--ink);transition:background-color .9s var(--ease-soft),color .9s var(--ease-soft);-webkit-text-size-adjust:100%}
body{font:400 1rem/1.6 var(--f-text);background:var(--bg);transition:background-color .9s var(--ease-soft);overflow-x:hidden;min-height:100svh}
a{color:inherit}
:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:4px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
#escena{position:fixed;inset:0;width:100%;height:100%;display:block;z-index:0;touch-action:pan-y}
.gota-css,.hueco{display:none}
.sin-webgl .gota-css{display:block;position:fixed;z-index:0;width:min(38vw,440px);aspect-ratio:1;right:12vw;top:50%;translate:0 -50%;border-radius:52% 48% 50% 50%/50% 52% 48% 50%;background:radial-gradient(circle at 34% 28%,#fff6d6 0 3%,#f4cd5c 11%,var(--oil) 42%,#7a5a0a 100%);box-shadow:0 0 120px -20px var(--oil)}
.top{position:fixed;z-index:3;top:0;left:0;right:0;display:flex;align-items:center;justify-content:space-between;padding:22px var(--gutter)}
.top::before{content:"";position:absolute;inset:0 0 -36px;background:linear-gradient(var(--bg) 35%,transparent);opacity:0;transition:opacity .5s;pointer-events:none;z-index:-1}
.desplazado .top::before{opacity:1}
.marca{display:flex;align-items:baseline;gap:14px;text-decoration:none}
.marca-n{font:italic 400 1.9rem/1 var(--f-display);font-variation-settings:"opsz" 144,"SOFT" 100;letter-spacing:-.02em}
.marca-s{font:500 .7rem/1 var(--f-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
.tema{position:relative;display:flex;border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--ink);font:500 .7rem/1 var(--f-mono);letter-spacing:.12em;text-transform:uppercase;cursor:pointer;padding:3px}
.tema::before{content:"";position:absolute;top:3px;bottom:3px;left:3px;width:calc(50% - 3px);border-radius:999px;background:var(--ink);transition:transform .6s var(--ease-out)}
.tema[aria-pressed="true"]::before{transform:translateX(100%)}
.tema-o{position:relative;padding:9px 16px;min-width:74px;text-align:center;transition:color .6s var(--ease-out)}
.tema[aria-pressed="false"] .tema-o:nth-child(2),.tema[aria-pressed="true"] .tema-o:nth-child(3){color:var(--bg)}
main{position:relative;z-index:1}
.hero{position:relative;min-height:100svh;display:flex;align-items:flex-end;padding:0 var(--gutter) max(15svh,120px)}
.hero-txt{max-width:40rem}
.eyebrow{font:500 .72rem/1.4 var(--f-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--accent);display:flex;align-items:center;gap:12px}
.eyebrow::before{content:"";width:28px;height:1px;background:currentColor}
h1,h2{font-family:var(--f-display);font-weight:340;font-variation-settings:"opsz" 144,"SOFT" 100;letter-spacing:-.035em;line-height:.94}
h1{font-size:clamp(3rem,1.2rem + 5.6vw,7.4rem);margin:22px 0 26px}
h1 .l{display:block;overflow:hidden;padding-bottom:.08em}
h1 .l>*{display:block}
em{font-style:italic;color:var(--accent);font-variation-settings:"opsz" 144,"SOFT" 100}
.lead{font-size:clamp(1rem,.92rem + .3vw,1.15rem);color:var(--muted);max-width:34rem}
.acciones{display:flex;flex-wrap:wrap;gap:12px;margin-top:30px}
.btn,.btn-sec{display:inline-flex;align-items:center;gap:10px;min-height:48px;padding:0 24px;border-radius:999px;font:500 .95rem/1 var(--f-text);text-decoration:none;transition:transform .5s var(--ease-out),background-color .9s var(--ease-soft),color .9s var(--ease-soft),border-color .5s}
.btn{background:var(--btn-bg);color:var(--btn-ink)}
.btn-sec{border:1px solid var(--line)}
.btn:hover,.btn-sec:hover{transform:translateY(-2px)}
.btn-sec:hover{border-color:var(--ink)}
.btn svg{width:16px;height:16px}
.precio{margin-top:22px;font:400 .78rem/1.4 var(--f-mono);letter-spacing:.06em;color:var(--muted)}
.precio strong{color:var(--ink);font-weight:500}
.etiqueta{position:fixed;z-index:1;left:0;top:0;display:flex;align-items:center;gap:12px;font:400 .7rem/1 var(--f-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);pointer-events:none;white-space:nowrap;opacity:0;transition:opacity .6s}
.etiqueta::after{content:"";width:72px;height:1px;background:var(--line)}
.hud{position:absolute;left:var(--gutter);right:var(--gutter);bottom:30px;display:flex;align-items:center;justify-content:space-between;gap:24px;font:400 .7rem/1.3 var(--f-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
.desliza{display:flex;align-items:center;gap:10px;text-decoration:none}
.desliza span{display:block;width:1px;height:26px;background:var(--line);position:relative;overflow:hidden}
.desliza span::after{content:"";position:absolute;left:0;top:-40%;width:1px;height:40%;background:var(--ink);animation:cae 2.4s var(--ease-soft) infinite}
@keyframes cae{to{top:100%}}
.hud-d{display:flex;align-items:center;gap:28px;list-style:none}
.hud-d b{color:var(--ink);font-weight:500;margin-right:6px}
.tocar{border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--ink);font:inherit;letter-spacing:inherit;text-transform:inherit;padding:10px 16px;cursor:pointer;transition:border-color .4s}
.tocar:hover{border-color:var(--ink)}
.ficha{position:relative;min-height:100svh;display:grid;grid-template-columns:repeat(12,1fr);column-gap:24px;padding:clamp(110px,15svh,150px) var(--gutter) 28px}
.ficha-in{grid-column:7/13;max-width:38rem}
h2{font-size:clamp(2.4rem,1.2rem + 3.4vw,4.6rem);margin:20px 0 22px}
.perfil{display:grid;gap:14px;margin:34px 0 36px}
.perfil div{display:grid;grid-template-columns:7.5rem 1fr 3rem;align-items:center;gap:16px}
.perfil dt{font-size:.92rem}
.perfil dd{font:400 .78rem/1 var(--f-mono);color:var(--muted);text-align:right}
.barra{height:6px;background:repeating-linear-gradient(90deg,var(--line) 0 calc(10% - 3px),transparent calc(10% - 3px) 10%)}
.barra i{display:block;height:100%;width:calc(var(--v)*10%);background:repeating-linear-gradient(90deg,var(--accent) 0 calc(100%/var(--v) - 3px),transparent calc(100%/var(--v) - 3px) calc(100%/var(--v)));transform-origin:left;transform:scaleX(0);transition:transform 1.3s var(--ease-out) calc(var(--i)*110ms + .1s)}
.ficha.visto .barra i{transform:scaleX(1)}
.datos{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line)}
.datos div{padding:16px 16px 16px 0;border-bottom:1px solid var(--line)}
.datos dt{font:400 .66rem/1.3 var(--f-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.datos dd{font-size:.95rem;line-height:1.35}
.contacto{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin-top:32px}
.contacto a:not(.btn){font:400 .82rem/1 var(--f-mono);text-decoration:none;border-bottom:1px solid var(--line);padding:6px 0}
.pie{grid-column:1/13;align-self:end;margin-top:48px;display:flex;justify-content:space-between;gap:16px;font:400 .68rem/1.4 var(--f-mono);letter-spacing:.08em;color:var(--muted)}
.entra{opacity:0;transform:translateY(18px);animation:entra 1s var(--ease-out) forwards;animation-delay:var(--d,0s)}
h1 .l>*{transform:translateY(105%);animation:sube 1.2s var(--ease-out) forwards;animation-delay:var(--d,0s)}
@keyframes entra{to{opacity:1;transform:none}}
@keyframes sube{to{transform:none}}
@media (max-width:1000px){
.ficha-in{grid-column:6/13}
.hud-d li:nth-child(3){display:none}
}
@media (max-width:760px){
.top{position:absolute;padding:16px var(--gutter)}
.marca-s{display:none}
.tema-o{min-width:58px;padding:8px 12px}
.hero{padding-bottom:36px}
.eyebrow{font-size:.66rem;letter-spacing:.1em}
h1{margin:16px 0 18px}
.acciones{margin-top:22px}
.btn,.btn-sec{padding:0 20px}
.precio{margin-top:16px}
.hud,.etiqueta{display:none}
.sin-webgl .gota-css{width:62vw;right:auto;left:50%;top:31svh;translate:-50% -50%}
.ficha{display:block;padding-top:96px}
.hueco{display:block;height:min(48svh,420px)}
.perfil div{grid-template-columns:6.2rem 1fr 2.6rem;gap:12px}
.datos{grid-template-columns:1fr 1fr}
.pie{flex-direction:column;margin-top:0}
}
@media (prefers-reduced-motion:reduce){
.entra,h1 .l>*{animation:none;opacity:1;transform:none}
.barra i{transition:none;transform:none}
.desliza span::after{animation:none;display:none}
html,body,.btn,.btn-sec,.tema::before,.tema-o{transition:none}
}
```
Notas de maquetación que no hay que perder:
- La cabecera es `fixed`. Cuando `scrollY > 40` el JS pone la clase `desplazado` en `<html>` y aparece detrás de la cabecera una banda en degradado de `--bg` a transparente (`.top::before`, 36 px más alta que la cabecera) para que el texto que sube no se mezcle con la marca.
- El conmutador es **un solo botón** con `aria-pressed` (`true` = Día). Su nombre accesible es «Fondo claro» (texto `.sr`). La píldora `::before` (color `--ink`) se desliza al 100 % con `.6s var(--ease-out)`; la opción activa toma el color `--bg`. Selecciona las opciones con `:nth-child(2)` y `:nth-child(3)` (el primer hijo es el `.sr`).
- Las barras de perfil son dos `repeating-linear-gradient` en 10 segmentos con 3 px de separación: la pista en `--line`, el relleno en `--accent` con `width: v × 10 %` y los mismos segmentos (`calc(100%/var(--v) - 3px)`). El relleno crece con `scaleX` desde la izquierda cuando la ficha entra en pantalla.
- En la ficha, el precio y los impuestos van con espacios duros (` `) para que «€ impuestos incl.» no se parta.
## Movimiento
| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Antetítulo portada | opacity, translateY | 0, 18px → 1, 0 | 1 s | `cubic-bezier(.19,1,.22,1)` | 0,10 s | carga |
| h1 línea 1 (span interior, con `overflow:hidden` en `.l`) | translateY | 105 % → 0 | 1,2 s | `cubic-bezier(.19,1,.22,1)` | 0,18 s | carga |
| h1 línea 2 (`em`) | translateY | 105 % → 0 | 1,2 s | ídem | 0,28 s | carga |
| Entradilla | opacity, translateY | 0, 18px → 1, 0 | 1 s | ídem | 0,50 s | carga |
| Botones | ídem | ídem | 1 s | ídem | 0,62 s | carga |
| Precio | ídem | ídem | 1 s | ídem | 0,72 s | carga |
| HUD | ídem | ídem | 1 s | ídem | 0,90 s | carga |
| Gota: escala de entrada | escala uniforme | 0 → 1 | muelle | rigidez 70, amortiguación 9 (con un pequeño rebote) | — | carga |
| Gota: respiración | desplazamiento radial | ruido simplex 2 octavas, tiempo `uTime*.22` | continuo | — | — | siempre |
| Gota: flotación | posición y | `+ sin(t·0,9)·0,045` | continuo | — | — | siempre |
| Gota: giro | rotación x / y | x = `sin(t·0,3)·0,12 − py·0,25`; y = `t·0,12 + e·π·0,6 + px·0,35` | continuo | — | — | siempre + scroll |
| Gota: seguir al puntero | posición | `+ px·0,12`, `+ py·0,08` | — | suavizado `1−exp(−dt·2,5)` | — | puntero |
| Gota: hundimiento | `uPress` | 0 → 1 | — | `1−exp(−dt·6)` | — | puntero sobre la gota |
| Onda | desplazamiento | `0,03·sin(g·7 − edad·9)`, frente `edad·1,3`, atenuación `exp(−edad·1,1)·exp(−g·0,6)`, vida 4,5 s | 4,5 s | — | — | entrar/moverse sobre la gota (máx. una cada 0,45 s), clic, botón, automática |
| Bamboleo | escala x/z `1−q·0,06`, y `1+q·0,1` | impulso `qv += 2,4·f`, `sv −= 0,6·f` | muelle | rigidez 150, amortiguación 6 | — | clic/toque (f = 1), botón (f = 1,2), onda automática (f = 0,35) |
| Onda automática | — | dirección frontal ± 0,5 aleatoria | — | — | primera a 1,1 s | sin actividad del puntero durante 4 s, cada 3,2 s |
| Viaje con scroll (escritorio) | posición y escala | portada `(0,21·visW, 0,03·visH, min(1, visW·0,12))` → ficha `(−0,22·visW, 0, min(0,92, visW·0,11))` | ligado al scroll | easeInOutCubic sobre `scrollY / ficha.offsetTop` | — | scroll |
| Etiqueta «Fig. 01» | opacity | `1 − e·3` (0 hasta que la escala supera 0,6) | — | — | — | scroll |
| Tema (escena) | colores de fondo, líneas, halo, sombra, atenuación y tinte de la gota | tema anterior → nuevo | ~1 s | `1−exp(−dt·3,2)` por fotograma | — | conmutador |
| Tema (interfaz) | background-color, color | — | 0,9 s | `cubic-bezier(.65,0,.35,1)` | — | conmutador |
| Píldora del conmutador | translateX | 0 → 100 % | 0,6 s | `cubic-bezier(.19,1,.22,1)` | — | conmutador |
| Barras de la ficha | scaleX | 0 → 1 | 1,3 s | `cubic-bezier(.19,1,.22,1)` | `i·110 ms + 0,1 s` | la ficha entra al 25 % (IntersectionObserver, una vez) |
| Línea de «Desliza» | top | −40 % → 100 % | 2,4 s, infinito | `cubic-bezier(.65,0,.35,1)` | — | siempre |
| Banda de la cabecera | opacity | 0 → 1 | 0,5 s | lineal | — | `scrollY > 40` |
| Botones | translateY | 0 → −2px | 0,5 s | `cubic-bezier(.19,1,.22,1)` | — | hover |
## Código clave
Este es el módulo completo, literal. Va en `<script type="module">` después del import map. Puntos delicados:
- **La deformación** se inyecta en `MeshPhysicalMaterial` con `onBeforeCompile`: se sustituyen `#include <beginnormal_vertex>` (para calcular la normal de la superficie deformada con dos vecinos a 0,012 en el plano tangente) y `#include <begin_vertex>` (posición deformada). Sin recalcular normales la gota se vería como una esfera lisa con silueta rara.
- `gota(d)` recibe la dirección unitaria del vértice y devuelve el desplazamiento radial: ruido (0,075 a frecuencia 0,75 + 0,014 a frecuencia 1,6), el hundimiento bajo el puntero `uPress·(−0,2·k¹⁴ + 0,05·k³)` con `k = max(dot(d, uPointer), 0)`, y hasta 4 ondas, cada una con `uRip[i] = (dirección, instante de inicio)`, que usan la distancia geodésica `g = acos(dot(d, dir))`.
- `uPointer` y las ondas están en **espacio de objeto** (`gota.worldToLocal`), así viajan con la gota cuando gira.
- El fondo es un plano a z = −3 escalado para cubrir la vista, con `toneMapped:false` para que su color coincida con `--bg`. Recibe la posición de la gota proyectada en su plano (`× 10/7`, porque la cámara está a z = 7). El halo tiene un núcleo `1,4·exp(−r²·5)` que puede pasar de 1: la transmisión lo lee en coma flotante y la gota se ilumina desde dentro.
- Los parámetros por tema de la escena: Noche `lineaA .05, brillo 1, sombra 0, atenua 1.2, tinte .7`; Día `lineaA .07, brillo .42, sombra .16, atenua .62, tinte .5` (tinte = cuánto se mezcla `--oil` con blanco para el `color` del material; atenua = `attenuationDistance`).
- En móvil (`camera.aspect < .8`) la gota va anclada a la portada (sube con el scroll 1:1) y, una vez fuera de pantalla, se ancla al centro del `.hueco` del final: nunca pasa por encima del texto.
```js
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const root = document.documentElement;
const $ = (s) => document.querySelector(s);
const canvas = $('#escena'), ficha = $('#ficha'), etiqueta = $('.etiqueta'), botonTema = $('.tema');
const mqReduce = matchMedia('(prefers-reduced-motion: reduce)');
let reduce = mqReduce.matches;
const css = (n) => getComputedStyle(root).getPropertyValue(n).trim();
// Barras de la ficha y tema: funcionan también sin WebGL
new IntersectionObserver((es, o) => es.forEach((e) => { if (e.isIntersecting) { ficha.classList.add('visto'); o.disconnect(); } }), { threshold: .25 }).observe(ficha);
const test = document.createElement('canvas');
const hayWebGL = !!(window.WebGL2RenderingContext && test.getContext('webgl2'));
let alCambiarTema = () => {};
botonTema.addEventListener('click', () => {
const dia = botonTema.getAttribute('aria-pressed') !== 'true';
botonTema.setAttribute('aria-pressed', String(dia));
root.dataset.tema = dia ? 'dia' : 'noche';
alCambiarTema();
});
const marcarScroll = () => root.classList.toggle('desplazado', scrollY > 40);
addEventListener('scroll', marcarScroll, { passive: true }); marcarScroll();
if (!hayWebGL) root.classList.add('sin-webgl');
else iniciar();
function iniciar() {
const NOISE = `
vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;}
vec4 perm(vec4 x){return mod289(((x*34.)+10.)*x);}
float snoise(vec3 v){
const vec2 C=vec2(1./6.,1./3.);
vec3 i=floor(v+dot(v,C.yyy));
vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz);
vec3 l=1.-g;
vec3 i1=min(g.xyz,l.zxy);
vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx;
vec3 x2=x0-i2+C.yyy;
vec3 x3=x0-.5;
i=mod289(i);
vec4 p=perm(perm(perm(i.z+vec4(0.,i1.z,i2.z,1.))+i.y+vec4(0.,i1.y,i2.y,1.))+i.x+vec4(0.,i1.x,i2.x,1.));
vec4 j=p-49.*floor(p*(1./49.));
vec4 xq=floor(j*(1./7.));
vec4 yq=floor(j-7.*xq);
vec4 x=(xq*2.+.5)/7.-1.;
vec4 y=(yq*2.+.5)/7.-1.;
vec4 h=1.-abs(x)-abs(y);
vec4 b0=vec4(x.xy,y.xy);
vec4 b1=vec4(x.zw,y.zw);
vec4 s0=floor(b0)*2.+1.;
vec4 s1=floor(b1)*2.+1.;
vec4 sh=-step(h,vec4(0.));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 g0=vec3(a0.xy,h.x);
vec3 g1=vec3(a0.zw,h.y);
vec3 g2=vec3(a1.xy,h.z);
vec3 g3=vec3(a1.zw,h.w);
vec4 n=inversesqrt(vec4(dot(g0,g0),dot(g1,g1),dot(g2,g2),dot(g3,g3)));
g0*=n.x;g1*=n.y;g2*=n.z;g3*=n.w;
vec4 m=max(.5-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
m=m*m;
return 105.*dot(m*m,vec4(dot(g0,x0),dot(g1,x1),dot(g2,x2),dot(g3,x3)));
}`;
const movil = matchMedia('(max-width: 760px)').matches || matchMedia('(pointer: coarse)').matches;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, movil ? 1.5 : 2));
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = 1;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, .1, 50);
camera.position.set(0, 0, 7);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), .04).texture;
scene.environmentIntensity = .9;
pmrem.dispose();
const key = new THREE.DirectionalLight(0xfff1d6, 2.2); key.position.set(-3, 4, 5); scene.add(key);
const rim = new THREE.DirectionalLight(0xffd27a, 1.4); rim.position.set(3, -2, -4); scene.add(rim);
// Paleta por tema
const oil = new THREE.Color(css('--oil'));
const TEMAS = {
noche: { bg: new THREE.Color(css('--bg-noche')), borde: .62, linea: new THREE.Color('#f3e6c4'), lineaA: .05, brillo: 1, sombra: 0, atenua: 1.2, tinte: .7 },
dia: { bg: new THREE.Color(css('--bg-dia')), borde: .9, linea: new THREE.Color('#2c2610'), lineaA: .07, brillo: .42, sombra: .16, atenua: .62, tinte: .5 },
};
const T = () => TEMAS[root.dataset.tema] || TEMAS.noche;
// Fondo de estudio: curvas de nivel de la montaña, halo y cáustica
const fondoU = {
uBg: { value: T().bg.clone() }, uBg2: { value: T().bg.clone().multiplyScalar(T().borde) },
uLine: { value: T().linea.clone() }, uLineA: { value: T().lineaA }, uOil: { value: oil },
uGlow: { value: T().brillo }, uSombra: { value: T().sombra }, uTime: { value: 0 }, uDrop: { value: new THREE.Vector2() }, uDropS: { value: 1 },
};
const fondo = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({
uniforms: fondoU, toneMapped: false, depthWrite: false,
vertexShader: `varying vec2 vP;
void main(){ vec4 w=modelMatrix*vec4(position,1.); vP=w.xy; gl_Position=projectionMatrix*viewMatrix*w; }`,
fragmentShader: `uniform vec3 uBg,uBg2,uLine,uOil; uniform float uLineA,uGlow,uSombra,uTime,uDropS; uniform vec2 uDrop; varying vec2 vP;
${NOISE}
void main(){
vec2 p=vP;
float h=(snoise(vec3(p*.3,uTime*.012))*.8+snoise(vec3(p*.75+7.3,uTime*.02))*.22)*3.;
float w=fwidth(h);
float d=abs(fract(h+.5)-.5);
float line=1.-smoothstep(w*.5,w*1.6,d);
vec2 q=p-uDrop;
float r2=dot(q,q)/(uDropS*uDropS);
vec3 col=mix(uBg,uBg2,smoothstep(1.5,9.,length(p*vec2(.55,1.))));
float halo=exp(-r2*1.5);
col+=uOil*uGlow*(.07*exp(-r2*.3)+.3*halo+1.4*exp(-r2*5.));
vec2 o=(q-vec2(.38,-.8)*uDropS)/uDropS;
col*=1.-uSombra*exp(-dot(o*vec2(1.,1.6),o*vec2(1.,1.6))*1.4);
vec2 c=(q-vec2(.3,-.62)*uDropS)/uDropS;
float caus=exp(-dot(c*vec2(1.7,3.),c*vec2(1.7,3.))*2.2);
col+=uOil*uGlow*.42*caus;
col=mix(col,uLine,line*uLineA*(1.-halo));
col*=1.-line*halo*uGlow*.6;
col+=(fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453)-.5)*.012;
gl_FragColor=vec4(col,1.);
#include <colorspace_fragment>
}`,
}));
fondo.position.z = -3;
fondo.renderOrder = -1;
scene.add(fondo);
// La gota
const gotaU = {
uTime: { value: 0 }, uAmp: { value: 1 }, uPointer: { value: new THREE.Vector3(0, 0, 1) }, uPress: { value: 0 },
uRip: { value: [0, 1, 2, 3].map(() => new THREE.Vector4(0, 0, 1, -99)) },
};
const mat = new THREE.MeshPhysicalMaterial({
color: oil.clone().lerp(new THREE.Color('#ffffff'), T().tinte), metalness: 0, roughness: .05,
transmission: 1, thickness: 1.1, ior: 1.47, dispersion: .12,
attenuationColor: oil.clone().lerp(new THREE.Color('#ffffff'), .25), attenuationDistance: T().atenua,
clearcoat: 1, clearcoatRoughness: .03, specularIntensity: 1,
});
mat.onBeforeCompile = (sh) => {
Object.assign(sh.uniforms, gotaU);
sh.vertexShader = sh.vertexShader
.replace('#include <common>', `#include <common>
uniform float uTime,uAmp,uPress; uniform vec3 uPointer; uniform vec4 uRip[4];
${NOISE}
float gota(vec3 d){
float t=uTime*.22;
float n=(snoise(d*.75+vec3(0.,t,t*.6))*.075+snoise(d*1.6-vec3(t*1.3,0.,t))*.014)*uAmp;
float k=max(dot(d,uPointer),0.);
n+=uPress*(-.2*pow(k,14.)+.05*pow(k,3.));
for(int i=0;i<4;i++){
float age=uTime-uRip[i].w;
if(age<0.||age>4.5) continue;
float g=acos(clamp(dot(d,uRip[i].xyz),-1.,1.));
float frente=smoothstep(0.,.4,age*1.3-g);
n+=.03*sin(g*7.-age*9.)*frente*exp(-age*1.1)*exp(-g*.6);
}
return n;
}
vec3 gotaPos(vec3 d){ return d*(1.+gota(d)); }`)
.replace('#include <beginnormal_vertex>', `vec3 gDir=normalize(position);
vec3 gTg=normalize(cross(gDir,abs(gDir.y)<.99?vec3(0.,1.,0.):vec3(1.,0.,0.)));
vec3 gBt=cross(gDir,gTg);
vec3 gP=gotaPos(gDir);
vec3 gP1=gotaPos(normalize(gDir+gTg*.012));
vec3 gP2=gotaPos(normalize(gDir+gBt*.012));
vec3 objectNormal=normalize(cross(gP1-gP,gP2-gP));`)
.replace('#include <begin_vertex>', 'vec3 transformed=gP;');
};
mat.customProgramCacheKey = () => 'gota-aceite';
const gota = new THREE.Mesh(new THREE.IcosahedronGeometry(1, movil ? 40 : 64), mat);
scene.add(gota);
// Estado
const S = { t: 0, press: 0, pressT: 0, rip: 0, lastRip: -9, lastAct: -9, s: reduce ? 1 : 0, sv: 0, q: 0, qv: 0, px: 0, py: 0, tx: 0, ty: 0, auto: 1.1 };
const view = { visH: 1, visW: 1, pxW: 1 };
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const easeIO = (x) => x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
const lerp = (a, b, k) => a + (b - a) * k;
function resize() {
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
view.visH = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camera.position.z;
view.visW = view.visH * camera.aspect;
view.pxW = view.visH / h;
const bh = view.visH * 10 / 7 * 1.15;
fondo.scale.set(bh * Math.max(camera.aspect, .5), bh, 1);
const hu = $('.hueco');
view.hueco = hu.offsetHeight ? hu.getBoundingClientRect().top + scrollY + hu.offsetHeight / 2 : 0;
}
function layout() {
const { visH, visW, pxW } = view;
const top = Math.max(1, ficha.offsetTop);
const y = scrollY;
if (camera.aspect < .8 && view.hueco) {
// Móvil: la gota viaja con la portada y reaparece al final, sobre el pie
const s0 = Math.min(visW * .31, .95), y0 = visH * .225;
const salida = innerHeight / 2 - y0 / pxW + s0 / pxW * 1.3;
if (y < salida) return { x: 0, y: y0 + y * pxW, s: s0, e: 0 };
return { x: 0, y: (innerHeight / 2 - (view.hueco - y)) * pxW, s: Math.min(visW * .3, .9), e: 1 };
}
const e = easeIO(clamp(y / top, 0, 1));
const a = { x: visW * .21, y: visH * .03, s: Math.min(1, visW * .12) };
const b = { x: -visW * .22, y: 0, s: Math.min(.92, visW * .11) };
const extra = Math.max(0, y - top) * pxW;
return { x: lerp(a.x, b.x, e), y: lerp(a.y, b.y, e) + extra, s: lerp(a.s, b.s, e), e };
}
function ripple(dirLocal, t = S.t) {
gotaU.uRip.value[S.rip % 4].set(dirLocal.x, dirLocal.y, dirLocal.z, t);
S.rip++; S.lastRip = t;
}
const v3 = new THREE.Vector3(), tmp = new THREE.Vector3();
function frente() { return gota.worldToLocal(tmp.copy(gota.position).add(v3.set(0, 0, 1))).normalize(); }
function empujon(f = 1) { S.qv += 2.4 * f; S.sv -= .6 * f; }
// Puntero
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), esfera = new THREE.Sphere(), hit = new THREE.Vector3();
function tocaGota(cx, cy) {
ndc.set(cx / innerWidth * 2 - 1, -(cy / innerHeight) * 2 + 1);
ray.setFromCamera(ndc, camera);
esfera.set(gota.position, 1.08 * gota.scale.x);
return ray.ray.intersectSphere(esfera, hit) ? gota.worldToLocal(hit.clone()).normalize() : null;
}
addEventListener('pointermove', (ev) => {
S.tx = ev.clientX / innerWidth * 2 - 1; S.ty = -(ev.clientY / innerHeight) * 2 + 1;
const d = tocaGota(ev.clientX, ev.clientY);
S.lastAct = S.t;
if (d) {
gotaU.uPointer.value.lerp(d, S.pressT ? .35 : 1).normalize();
if (!S.pressT || S.t - S.lastRip > .45) { if (!reduce) ripple(d); }
S.pressT = 1;
} else S.pressT = 0;
if (reduce) pedir();
}, { passive: true });
addEventListener('pointerdown', (ev) => {
const d = tocaGota(ev.clientX, ev.clientY);
if (!d || reduce) return;
S.lastAct = S.t; ripple(d); empujon(1);
}, { passive: true });
document.addEventListener('pointerleave', () => { S.pressT = 0; S.tx = S.ty = 0; });
$('.tocar').addEventListener('click', () => { if (reduce) return; S.lastAct = S.t; ripple(frente()); empujon(1.2); });
// Tema
const col = { bg: fondoU.uBg.value, bg2: fondoU.uBg2.value, line: fondoU.uLine.value };
const BLANCO = new THREE.Color('#ffffff');
const objetivo = { bg: new THREE.Color(), bg2: new THREE.Color(), line: new THREE.Color(), tinte: new THREE.Color() };
function fijarObjetivo() {
const t = T();
objetivo.bg.copy(t.bg); objetivo.bg2.copy(t.bg).multiplyScalar(t.borde); objetivo.line.copy(t.linea);
objetivo.lineA = t.lineaA; objetivo.brillo = t.brillo; objetivo.sombra = t.sombra; objetivo.atenua = t.atenua;
objetivo.tinte.copy(oil).lerp(BLANCO, t.tinte);
}
fijarObjetivo();
alCambiarTema = () => { fijarObjetivo(); if (reduce) { paso(0); pedir(); } };
function paso(dt) {
if (!reduce) S.t += dt;
const t = reduce ? 6 : S.t;
gotaU.uTime.value = t; fondoU.uTime.value = t;
// Tema: transición suave de colores
const k = reduce ? 1 : 1 - Math.exp(-dt * 3.2);
col.bg.lerp(objetivo.bg, k); col.bg2.lerp(objetivo.bg2, k); col.line.lerp(objetivo.line, k);
fondoU.uLineA.value = lerp(fondoU.uLineA.value, objetivo.lineA, k);
fondoU.uGlow.value = lerp(fondoU.uGlow.value, objetivo.brillo, k);
fondoU.uSombra.value = lerp(fondoU.uSombra.value, objetivo.sombra, k);
mat.attenuationDistance = lerp(mat.attenuationDistance, objetivo.atenua, k);
mat.color.lerp(objetivo.tinte, k);
// Muelles: escala de entrada y bamboleo tras un toque
if (!reduce) {
S.sv += ((1 - S.s) * 70 - S.sv * 9) * dt; S.s += S.sv * dt;
S.qv += (-S.q * 150 - S.qv * 6) * dt; S.q += S.qv * dt;
S.press += (S.pressT - S.press) * (1 - Math.exp(-dt * 6));
S.px += (S.tx - S.px) * (1 - Math.exp(-dt * 2.5));
S.py += (S.ty - S.py) * (1 - Math.exp(-dt * 2.5));
// Onda automática si nadie toca (táctil, vídeo, reposo)
if (S.t > S.auto && S.t - S.lastAct > 4 && S.t - S.lastRip > 3.2) {
const a = Math.random() * Math.PI * 2;
ripple(frente().add(tmp.set(Math.cos(a) * .5, Math.sin(a) * .5, 0)).normalize()); empujon(.35);
S.auto = S.t + 3.2;
}
}
gotaU.uPress.value = reduce ? 0 : S.press;
const L = layout();
const bob = reduce ? 0 : Math.sin(t * .9) * .045;
gota.position.set(L.x + S.px * .12, L.y + bob + S.py * .08, 0);
const s = L.s * Math.max(S.s, 0);
gota.scale.set(s * (1 - S.q * .06), s * (1 + S.q * .1), s * (1 - S.q * .06));
gota.rotation.set(Math.sin(t * .3) * .12 - S.py * .25, t * .12 + L.e * Math.PI * .6 + S.px * .35, 0);
fondoU.uDrop.value.set(gota.position.x * 10 / 7, gota.position.y * 10 / 7);
fondoU.uDropS.value = Math.max(s, .01) * 10 / 7;
// Etiqueta que acompaña a la gota
v3.copy(gota.position).project(camera);
const r = s / view.pxW;
const sx = (v3.x * .5 + .5) * innerWidth - r * .64, sy = (-v3.y * .5 + .5) * innerHeight - r * .8;
etiqueta.style.transform = `translate3d(${sx.toFixed(1)}px,${sy.toFixed(1)}px,0) translate(-100%,-50%)`;
etiqueta.style.opacity = String(clamp(1 - L.e * 3, 0, 1) * (S.s > .6 ? 1 : 0));
}
let raf = 0, last = 0, visible = true, pendiente = false;
function frame(now) {
raf = 0;
const dt = Math.min((now - (last || now)) / 1000, .05); last = now;
paso(dt); renderer.render(scene, camera);
if (activo()) raf = requestAnimationFrame(frame);
}
const activo = () => visible && !document.hidden && !reduce;
function arrancar() { if (activo() && !raf) { last = 0; raf = requestAnimationFrame(frame); } }
function pedir() {
if (pendiente) return; pendiente = true;
requestAnimationFrame(() => { pendiente = false; paso(0); renderer.render(scene, camera); });
}
resize();
document.fonts && document.fonts.ready.then(() => { resize(); if (reduce) pedir(); });
addEventListener('resize', () => { resize(); if (reduce) pedir(); });
addEventListener('scroll', () => { if (reduce) pedir(); }, { passive: true });
document.addEventListener('visibilitychange', arrancar);
new IntersectionObserver(([e]) => { visible = e.isIntersecting; arrancar(); }).observe(canvas);
mqReduce.addEventListener('change', (e) => { reduce = e.matches; if (reduce) { S.s = 1; S.q = 0; pedir(); } else arrancar(); });
renderer.compile(scene, camera);
if (reduce) pedir(); else arrancar();
}
```
## Interacción
- **Puntero (ratón)**: `pointermove` en `window`. Se lanza un rayo desde la cámara contra una esfera de radio `1,08 × escala` centrada en la gota. Si toca: `uPointer` se acerca a la dirección local del impacto (lerp 0,35 si ya estaba encima; directa al entrar), `pressT = 1` y nace una onda al entrar o cada 0,45 s mientras se mueve. Si no toca: `pressT = 0`. Siempre: `tx, ty` = posición normalizada −1…1 del puntero, que la gota sigue suavemente (se desplaza 0,12 / 0,08 y gira 0,35 / 0,25 rad hacia él).
- **Clic o toque** (`pointerdown`) sobre la gota: onda en el punto tocado + bamboleo (`empujon(1)`).
- **Botón «Tocar la gota»** (teclado y lector de pantalla): onda en el punto de la gota que mira a la cámara + `empujon(1.2)`.
- **Scroll**: en escritorio mueve la gota de derecha a izquierda y le suma media vuelta y un poco (`e·π·0,6`); en móvil la lleva con la página y la recoloca al final.
- **Conmutador Noche/Día**: cambia `data-tema`, `aria-pressed` y los objetivos de color de la escena; todo se interpola.
- **Táctil**: el toque sustituye al hover y, además, la onda automática mantiene la gota viva sin interacción. El canvas lleva `touch-action: pan-y` para no bloquear el scroll.
- `pointerleave` del documento: suelta el hundimiento y centra el seguimiento.
## Adaptación a móvil y tablet
- **> 1000 px**: la descrita. Ficha en columnas 7–12.
- **≤ 1000 px**: ficha en columnas 6–12; se oculta el tercer dato del HUD («21 °C molturación»).
- **≤ 760 px** (y cualquier pantalla con `camera.aspect < .8` para la escena):
- cabecera `absolute` (no fija), sin el subtítulo «Almazara · Arvale»; opciones del conmutador más estrechas;
- la gota se centra arriba: `x = 0`, `y = visH·0,225`, escala `min(visW·0,31, 0,95)`; el texto de la portada queda debajo, pegado al fondo (`padding-bottom: 36px`), con antetítulo a 0,66rem;
- se ocultan el HUD y la etiqueta «Fig. 01»;
- la ficha pasa a bloque (`padding-top: 96px`), la rejilla de datos a 2 columnas, el pie en columna, y aparece `.hueco` (`height: min(48svh, 420px)`) antes del pie: ahí reaparece la gota con escala `min(visW·0,3, 0,9)`;
- la geometría baja de detalle 64 a 40 y el DPR máximo a 1,5.
- Nunca hay scroll horizontal (`overflow-x:hidden` en `body`, textos con `clamp`).
## Accesibilidad y movimiento reducido
- HTML semántico: `header`, `main`, dos `section` con `aria-labelledby`, `h1`/`h2`, listas `dl` para el perfil y los datos, `footer`.
- Canvas, gota CSS, etiqueta, barras (cuyo valor se lee en el `dd` «8/10») y `.hueco` con `aria-hidden="true"`.
- Contraste AA en ambos temas (tinta sobre fondo > 12:1; `--muted` > 5:1; el acento de Día es `#7a5800`, no el dorado, para cumplir sobre crema; el botón dorado lleva texto `#17150b`).
- Foco visible: `outline: 2px solid var(--accent)` con 4 px de separación. Todos los controles son `a` o `button` nativos.
- `prefers-reduced-motion: reduce`: sin animaciones de entrada (todo visible al cargar), barras ya llenas, sin la línea animada de «Desliza», sin transiciones de color. En la escena: el tiempo se congela en `t = 6` (una pose bonita), escala 1, sin ondas, sin hundimiento, sin flotación, y **no hay bucle**: se renderiza un fotograma solo al cargar, al hacer scroll, al redimensionar, al cargar las fuentes y al cambiar de tema. Se escucha el cambio de la preferencia en caliente.
## Rendimiento
- DPR máximo 2 en escritorio y 1,5 en móvil o puntero grueso.
- `IcosahedronGeometry(1, 64)` en escritorio (unos 83 000 triángulos) y `(1, 40)` en móvil; el shader de ruido es barato.
- El bucle se para cuando el canvas no está en pantalla (IntersectionObserver, útil dentro de un iframe) o la pestaña está oculta (`visibilitychange`), y se reanuda al volver. `dt` se limita a 0,05 s.
- `resize` recalcula tamaño, aspecto, alto/ancho visibles en z = 0, escala del plano de fondo y la posición del `.hueco`.
- **Sin WebGL 2** (se comprueba con un canvas de prueba antes de crear el renderer, para no ensuciar la consola): se añade `sin-webgl` a `<html>` y se muestra `.gota-css`, una gota en degradado radial con `--oil` y un resplandor, en el mismo sitio. El resto de la página funciona igual (tema, barras, cabecera).
- `renderer.compile()` antes del primer fotograma para evitar el tirón de compilación.
## Personalización
- **Marca y textos**: `Alcor`, `Arvale`, `Valle Alto`, el titular (`Cae despacio,` / `sabe a monte.`), `18,90 €` (se escribe siempre seguido de «impuestos incl.»), `hola@alcor.com` y `+00 000 000 000`. Si el titular es más largo, cabe en dos líneas hasta unas 16 letras por línea en escritorio.
- **Color del aceite**: `#d9a521` en `--oil`. Controla el botón, el halo, la cáustica, la atenuación y el tinte de la gota. Para un aceite más verde prueba `#9fae2e`; para miel, `#e0901c`.
- **Fondos**: `#11120c` y `#eee5d1`. Si los cambias mucho, revisa `--muted` y `--accent` de ese tema para mantener el contraste.
- **Tema inicial**: cambia `data-tema` del `<html>` y el `aria-pressed` del botón a la vez.
- **Carácter de la gota**: más viscosa = baja `.075` a `.05` y `uTime*.22` a `.15`; más nerviosa = sube las ondas a `.045`. Refracción más fuerte = `thickness` 1.6.
- **Fondo del estudio**: número de curvas de nivel = el factor `*3.` de `h`; su intensidad = `lineaA` de cada tema.
## Comprobación final
1. Se abre sin errores ni avisos en consola y sin peticiones fallidas (solo jsDelivr y Google Fonts).
2. En 1440 × 900 la gota está a la derecha, dorada y luminosa (no marrón ni opaca), con las curvas de nivel visibles y deformadas dentro.
3. El titular entra línea a línea desde abajo con máscara; el resto aparece escalonado hasta 0,9 s.
4. Al pasar el cursor por la gota la superficie se hunde bajo el puntero y nacen ondas; al hacer clic, onda y bamboleo elástico.
5. Sin tocar nada, la gota respira, flota, gira despacio y cada 3,2 s hace una onda.
6. Al hacer scroll la gota cruza a la izquierda y gira; la ficha queda a la derecha, sin solaparse, y las barras se llenan en cascada.
7. La cabecera fija gana una banda de fondo al hacer scroll y el texto nunca se mezcla con la marca.
8. El conmutador Noche/Día cambia a crema con sombra bajo la gota y gota más ámbar; la transición dura alrededor de un segundo y no hay saltos.
9. A 390 × 844: gota arriba centrada, texto debajo sin solaparse, sin scroll horizontal; la gota desaparece al bajar y reaparece encima del pie.
10. El botón «Tocar la gota» y el conmutador funcionan con teclado y tienen foco visible.
11. Con movimiento reducido: todo estático y legible, sin bucle de animación, y el cambio de tema se ve al instante.
12. Sin WebGL aparece la gota en CSS en el mismo sitio y la página sigue siendo usable.
13. Al salir de pantalla (iframe fuera de vista o pestaña oculta) el bucle se detiene.
14. El HTML pesa menos de 40 KB.Prueba en frío
Pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos el resultado con la demo. Prueba hecha el 30-09-2026.

Prácticamente idéntica al original: misma gota refractiva, curvas de nivel, halo, textos, recorrido de scroll e interacción con el puntero. La única diferencia es un espacio duro que falta en un precio, invisible en pantalla.

Acierta textos, paleta, tipografía, la física de la gota (hundimiento, ondas, bamboleo) y el viaje de derecha a izquierda al hacer scroll. Pero en escritorio la gota sale bastante más grande, las curvas de nivel apenas se ven, y las barras de cata y el precio cambian de disposición y de mayúsculas.
Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.
Paso a paso
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.
Puedes
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 30-09-2026