
Malla de color
GratisFlujo · CSS
La pieza
Un gradiente vivo a pantalla completa hecho con un único shader WebGL de menos de 2 KB. El humo sube despacio desde la parte baja, denso y encendido abajo y deshilachado arriba, con los filos iluminados por una luz rasante y hebras finas que brillan siguiendo cada voluta. Al mover el puntero, el humo se enrosca a su alrededor y se va con el gesto; si nadie toca, un remolino invisible lo remueve en un ocho lento, así que en móvil también está vivo. Trae tres paletas conmutables con fundido de 1,2 s: Brasa (ámbar y cobre), Glaciar (azul hielo) y Ceniza (plata sobre negro). Funciona como fondo de hero, cabecera de una sección de manifiesto o telón de un evento; deja zonas negras donde el texto respira. Se pinta a resolución reducida y ajusta la calidad solo para ir a 60 fps.
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: Humo
Humo de color que sube desde abajo, se retuerce en filamentos luminosos y se enrosca alrededor del puntero sobre un negro cinematográfico: un gradiente vivo hecho con un solo shader.
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
Un fondo a pantalla completa (a sangre, sin scroll) pintado por un único fragment shader WebGL 1 de unos 1,9 KB de GLSL. No hay texturas, partículas ni simulación de fluidos con buffers: todo sale de ruido de valor en fbm de 5 octavas con **doble deformación de dominio** (domain warping), que dibuja volutas de humo que suben despacio. Sobre esa base:
- Una **máscara de columna** hace que el humo sea denso abajo y se deshilache hacia arriba, dejando zonas negras que respiran (ahí cabe un texto).
- Un **relieve falso** (la diferencia entre dos muestras del fbm desplazadas en diagonal) ilumina los filos del humo como si una luz rasante los tocara.
- Unos **filamentos** muy finos salen gratis de una curva de nivel del propio campo (`exp(-abs(f-.56)*38.)`): son las hebras brillantes que siguen el giro del humo.
- **Interacción**: el puntero gira el espacio a su alrededor (remolino con caída gaussiana), empuja el humo en la dirección del gesto y enciende un halo tenue proporcional a la energía del movimiento. Si nadie toca durante 2,6 s, un puntero invisible dibuja un ocho lento para que la pieza nunca se quede quieta.
- **Tres paletas** conmutables (Brasa, Glaciar, Ceniza) con transición de 1,2 s en espacio lineal. Cada paleta son 4 colores: fondo, humo (sombra), brillo (cuerpo) y luz (filos).
- Encima, solo una capa editorial mínima: una línea superior en mono («Humo · gradiente vivo» y la pista de interacción), un titular grande en Inter Tight 600 con la última palabra en peso 700 y color de acento (sin cursiva) abajo a la izquierda y el selector de paletas en cápsula abajo a la derecha.
El humo se pinta a una fracción de la resolución (0,62 de los píxeles físicos en escritorio, 0,5 en táctil) y el navegador lo escala: al ser un campo suave no se nota, y así va a 60 fps incluso en portátiles modestos. Si la media de fotograma supera 22 ms, la calidad baja sola.
## Librerías y esqueleto
Sin librerías: WebGL 1 a mano. Solo Google Fonts. Cabecera literal:
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Humo · gradiente WebGL interactivo</title>
<meta name="description" content="Humo de color que sube y se enrosca alrededor del puntero. Shader WebGL a pantalla completa con tres paletas.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
```
Fuentes: **Inter Tight** (500, 600 y 700, sin cursivas) para el titular y **Geist Mono** (400 y 500) para rótulos y botones. Las dos tienen ñ, tildes, ¿ y ¡.
Orden del documento: `<head>` → `<style>` → `<body>` con `.escena` (el lienzo) y `<main class="capa">` → un único `<script>` clásico al final (no hace falta módulo ni import map).
## Tokens de diseño
```css
:root {
--fondo: #0a0605;
--humo: #4a120a;
--brillo: #e0692c;
--luz: #ffd9a8;
--texto: #f5efe8;
--texto-suave: rgba(245, 239, 232, .62);
--linea: rgba(245, 239, 232, .16);
--serif: "Inter Tight", "Helvetica Neue", Arial, sans-serif;
--mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
--ease: cubic-bezier(.16, 1, .3, 1);
--gutter: clamp(16px, 3.2vw, 48px);
}
```
`--fondo`, `--humo`, `--brillo` y `--luz` son la paleta activa: el JS los reescribe al cambiar de paleta (los usan el fallback CSS y el anillo de foco). El resto del CSS, literal:
```css
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100svh;
overflow: hidden;
background: var(--fondo);
color: var(--texto);
font-family: var(--mono);
-webkit-font-smoothing: antialiased;
}
.escena {
position: fixed; inset: 0;
height: 100dvh;
background:
radial-gradient(120% 80% at 30% 105%, color-mix(in srgb, var(--brillo) 55%, transparent) 0%, transparent 60%),
radial-gradient(90% 70% at 80% 90%, color-mix(in srgb, var(--humo) 80%, transparent) 0%, transparent 65%),
var(--fondo);
transition: background-color 1.2s var(--ease);
}
canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
display: block;
opacity: 0;
transition: opacity 1.8s var(--ease);
touch-action: none;
}
.listo canvas { opacity: 1; }
.sin-webgl canvas { display: none; }
.capa {
position: fixed; inset: 0;
height: 100dvh;
pointer-events: none;
display: grid;
grid-template-rows: auto 1fr auto;
padding: var(--gutter);
padding-bottom: max(var(--gutter), env(safe-area-inset-bottom));
}
.arriba {
display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--texto-suave);
}
.arriba b { font-weight: 500; color: var(--texto); }
.pista { text-align: right; white-space: nowrap; }
.arriba > span:first-child { white-space: nowrap; }
.abajo {
display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap;
}
h1 {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: clamp(46px, 8.4vw, 132px);
line-height: .92;
letter-spacing: -.045em;
max-width: 9.5ch;
text-wrap: balance;
}
h1 em { font-style: normal; font-weight: 700; color: var(--brillo); }
h1 .p { display: inline-block; opacity: 0; transform: translateY(.35em); filter: blur(8px);
transition: opacity 1.4s var(--ease), transform 1.4s var(--ease), filter 1.4s var(--ease); }
.listo h1 .p, .sin-webgl h1 .p { opacity: 1; transform: none; filter: none; }
.paletas {
pointer-events: auto;
display: flex; gap: 6px;
padding: 5px;
border: 1px solid var(--linea);
border-radius: 999px;
background: rgba(0, 0, 0, .28);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
opacity: 0; transform: translateY(10px);
transition: opacity 1.2s var(--ease) .9s, transform 1.2s var(--ease) .9s;
}
.listo .paletas, .sin-webgl .paletas { opacity: 1; transform: none; }
.paletas button {
appearance: none; border: 0; cursor: pointer;
display: inline-flex; align-items: center; gap: 8px;
min-height: 36px; padding: 0 14px 0 10px;
border-radius: 999px;
background: transparent;
color: var(--texto-suave);
font: 500 11px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
transition: background-color .5s var(--ease), color .5s var(--ease);
}
.paletas button:hover { color: var(--texto); }
.paletas button[aria-checked="true"] { background: rgba(245, 239, 232, .12); color: var(--texto); }
.paletas button:focus-visible { outline: 2px solid var(--luz); outline-offset: 2px; }
.paletas i {
width: 12px; height: 12px; border-radius: 50%;
background: radial-gradient(circle at 35% 35%, var(--c3), var(--c2) 45%, var(--c1));
box-shadow: 0 0 0 1px rgba(255, 255, 255, .18);
}
@media (max-width: 640px) {
.arriba { font-size: 10px; }
.abajo { flex-direction: column; align-items: flex-start; gap: 20px; }
.paletas button { padding: 0 11px 0 9px; letter-spacing: .08em; }
}
@media (prefers-reduced-motion: reduce) {
canvas, h1 .p, .paletas { transition: none !important; }
h1 .p { opacity: 1; transform: none; filter: none; }
.paletas { opacity: 1; transform: none; }
}
```
## Estructura y contenido
```html
<body>
<div class="escena" aria-hidden="true"><canvas id="lienzo"></canvas></div>
<main class="capa">
<header class="arriba">
<span><b>Humo</b> · gradiente vivo</span>
<span class="pista" id="pista">Agita con el puntero</span>
</header>
<div></div>
<div class="abajo">
<h1 id="titular">Lo que queda en el <em>aire.</em></h1>
<div class="paletas" role="radiogroup" aria-label="Paleta de color">
<button type="button" role="radio" aria-checked="true" data-p="0" style="--c1:#4a120a;--c2:#e0692c;--c3:#ffd9a8"><i aria-hidden="true"></i>Brasa</button>
<button type="button" role="radio" aria-checked="false" data-p="1" tabindex="-1" style="--c1:#0b3f66;--c2:#33b4e0;--c3:#e8fbff"><i aria-hidden="true"></i>Glaciar</button>
<button type="button" role="radio" aria-checked="false" data-p="2" tabindex="-1" style="--c1:#2c2a2e;--c2:#9a979f;--c3:#f6f1e7"><i aria-hidden="true"></i>Ceniza</button>
</div>
</div>
</main>
```
- `.escena` (fija, `aria-hidden`) contiene el `<canvas id="lienzo">`. Su fondo CSS es a la vez el **fallback sin WebGL**: dos radiales (brillo abajo a la izquierda, humo abajo a la derecha) sobre `--fondo`.
- `<main class="capa">` es una rejilla de 3 filas (arriba · hueco · abajo) con `pointer-events: none`; solo el selector recupera `pointer-events: auto`, así el puntero llega al humo en toda la pantalla.
- Textos literales: «Humo» (peso 500) + « · gradiente vivo»; pista «Agita con el puntero» (en táctil el JS la cambia a «Toca y arrastra»); titular «Lo que queda en el aire.» (la última parte en peso 700 y color de acento, sin cursiva); botones «Brasa», «Glaciar» y «Ceniza».
- Cada botón lleva sus tres colores en `--c1/--c2/--c3` para pintar su bolita (radial al 35 % 35 %: luz → brillo al 45 % → humo).
## Movimiento
| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Canvas | opacity | 0 → 1 | 1,8 s | cubic-bezier(.16,1,.3,1) | 0 | clase `.listo` en `<html>` en el primer rAF |
| Palabras del titular (`.p`) | opacity, translateY, blur | 0, .35em, 8px → 1, 0, 0 | 1,4 s | cubic-bezier(.16,1,.3,1) | 0,25 s + 0,09 s × índice | `.listo` |
| Selector de paletas | opacity, translateY | 0, 10px → 1, 0 | 1,2 s | cubic-bezier(.16,1,.3,1) | 0,9 s | `.listo` |
| Humo (shader) | tiempo `t = uT*.05` | el patrón sube 2,6·t | continuo | lineal | — | bucle rAF; `uT` arranca en 18 |
| Puntero suavizado | posición `uM` | lerp con factor `(1-0.001^dt)·0.9` | continuo | exponencial | — | pointermove / pointerdown |
| Velocidad del puntero | `uV` | se amortigua `×0.08^dt`, módulo máximo 1,6 | continuo | exponencial | — | pointermove |
| Energía | `uE` 0 → 1 | lerp `1-0.02^dt` hacia `min(1, 0.9·vel + 2.5·distancia)` | continuo | exponencial | — | movimiento |
| Deriva automática | objetivo del puntero | ocho: `x=.5+sin(2.1s)·.26`, `y=.45+sin(3.3s)·.16`, con `s=now·0.00012` | continuo | — | tras 2,6 s sin tocar | inactividad |
| Paleta | 4 colores lineales | anterior → nueva | 1,2 s | ease-out cúbica `1-(1-x)^3` | 0 | clic, flechas o teclas 1–3 |
| Botón de paleta | fondo, color | transparente → rgba(245,239,232,.12) | 0,5 s | cubic-bezier(.16,1,.3,1) | 0 | `aria-checked="true"` |
## Código clave
### Fragment shader (literal)
Coordenadas: `uv` en [0,1] con la y hacia arriba (gl_FragCoord); `p` centrado y corregido por aspecto. Todos los colores llegan **en lineal** (el JS eleva cada canal del hex a 2,2) y el shader vuelve a gamma con `pow(c, .4545)` al final.
```glsl
precision highp float;
uniform vec2 uRes,uM,uV;uniform float uT,uE;uniform vec3 uBg,uA,uB,uL;
float h(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
const mat2 R=mat2(.8,.6,-.6,.8);
float fbm(vec2 p){float s=0.,a=.5;for(int i=0;i<5;i++){s+=a*n(p);p=R*p*2.03+17.1;a*=.5;}return s;}
void main(){
vec2 uv=gl_FragCoord.xy/uRes,k=vec2(uRes.x/uRes.y,1.);
vec2 p=(uv-.5)*k,m=(uM-.5)*k,d=p-m;
float fo=exp(-dot(d,d)*5.),a=fo*(.35+uE*2.4);
p=m+mat2(cos(a),-sin(a),sin(a),cos(a))*d-uV*fo*.5;
float t=uT*.05;
vec2 s=p*1.35+vec2(0.,-t*2.6);
vec2 q=vec2(fbm(s+vec2(0.,t)),fbm(s+vec2(5.2,1.3)-t));
vec2 r=vec2(fbm(s+3.2*q+vec2(1.7,9.2)+t*1.4),fbm(s+3.2*q+vec2(8.3,2.8)-t*.9));
vec2 w=s+2.8*r;
float f=fbm(w),f2=fbm(w+vec2(.035,.055));
float col=smoothstep(1.15,-.25,uv.y+(r.x-.5)*.8);
float de=smoothstep(.42,.98,f*(.45+col)+.22*length(q));
float li=clamp((f-f2)*9.+.5,0.,1.);
float fi=exp(-abs(f-.56)*38.)*(.25+col);
vec3 bg=uBg*(1.2-uv.y*.6)+uB*.05*(1.-uv.y)*(1.-uv.y);
vec3 sm=mix(uA,uB,clamp(length(r)*1.1-.3,0.,1.)*de);
vec3 c=bg+sm*de*1.3+uL*li*li*de*.55+mix(uB,uL,de)*fi*.4;
c+=uB*pow(de,3.)*col*.5;
c+=uL*fo*uE*.12;
vec2 v=uv-.5;c*=1.-dot(v,v)*.9;
c=c/(1.+c*.35);
c=pow(c,vec3(.4545))+(h(gl_FragCoord.xy+fract(uT))-.5)*.012;
gl_FragColor=vec4(c,1.);
}
```
Qué hace cada bloque, por si hay que tocarlo:
1. `h`, `n`, `fbm`: hash sin senos, ruido de valor con interpolación Hermite y fbm de 5 octavas con rotación `R` (0,8/0,6), lacunaridad 2,03 y desfase 17,1 entre octavas (así no se ve la rejilla del ruido).
2. **Remolino del puntero**: `fo = exp(-dot(d,d)*5.)` es la caída gaussiana alrededor del cursor; el espacio gira `a = fo*(.35+uE*2.4)` radianes alrededor de `m` y se desplaza `-uV*fo*.5` (el humo se va con el gesto). Con el puntero quieto queda un giro mínimo de 0,35 rad.
3. **Subida**: `s = p*1.35 + vec2(0., -t*2.6)` traslada el patrón hacia arriba.
4. **Doble deformación**: `q` (2 fbm) deforma `r` (2 fbm evaluados en `s+3.2*q`) y `r` deforma el campo final `w = s + 2.8*r`.
5. `f` y `f2` (la misma muestra desplazada `(.035,.055)`): su diferencia ×9 da la luz rasante `li`.
6. `col`: máscara de columna, 1 abajo → 0 arriba, con el borde ondulado por `r.x`.
7. `de`: densidad del humo, `smoothstep(.42,.98, f*(.45+col) + .22*length(q))`.
8. `fi`: filamentos, la curva de nivel 0,56 del campo.
9. Composición emisiva: fondo con un leve degradado vertical y un resplandor de `uB` abajo; se suma el humo (`mix(uA,uB,…)` × densidad × 1,3), la luz en los filos, los filamentos, un núcleo incandescente `uB*de³*col*.5` y el halo del puntero `uL*fo*uE*.12`. Después, viñeta `1-dot(v,v)*.9`, tonemap suave `c/(1+.35c)`, gamma y grano de ±0,6 % (evita bandas en los negros).
### JavaScript completo (literal)
```js
(() => {
// ---------- Paletas: fondo, humo (sombra), brillo (cuerpo) y luz (filos) ----------
const PALETAS = [
{ fondo: "#0a0605", humo: "#4a120a", brillo: "#e0692c", luz: "#ffd9a8" }, // Brasa
{ fondo: "#03070c", humo: "#0b3f66", brillo: "#33b4e0", luz: "#e8fbff" }, // Glaciar
{ fondo: "#060606", humo: "#2c2a2e", brillo: "#9a979f", luz: "#f6f1e7" } // Ceniza
];
const root = document.documentElement;
const canvas = document.getElementById("lienzo");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const tactil = matchMedia("(pointer: coarse)").matches;
if (tactil) document.getElementById("pista").textContent = "Toca y arrastra";
// Titular palabra a palabra
const h1 = document.getElementById("titular");
const trozos = [];
h1.childNodes.forEach((n) => {
if (n.nodeType === 3) n.textContent.split(/(\s+)/).forEach((w) => trozos.push(w.trim() ? `<span class="p">${w}</span>` : w));
else trozos.push(`<span class="p">${n.outerHTML}</span>`);
});
h1.innerHTML = trozos.join("");
h1.querySelectorAll(".p").forEach((s, i) => (s.style.transitionDelay = `${0.25 + i * 0.09}s`));
const hex = (h) => [1, 3, 5].map((i) => Math.pow(parseInt(h.slice(i, i + 2), 16) / 255, 2.2));
const setCSS = (p) => { for (const k in p) root.style.setProperty(`--${k}`, p[k]); };
// ---------- WebGL ----------
const gl = canvas.getContext("webgl", { antialias: false, alpha: false, premultipliedAlpha: false, powerPreference: "high-performance" });
const VERT = `attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}`;
const FRAG = `precision highp float;
uniform vec2 uRes,uM,uV;uniform float uT,uE;uniform vec3 uBg,uA,uB,uL;
float h(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
const mat2 R=mat2(.8,.6,-.6,.8);
float fbm(vec2 p){float s=0.,a=.5;for(int i=0;i<5;i++){s+=a*n(p);p=R*p*2.03+17.1;a*=.5;}return s;}
void main(){
vec2 uv=gl_FragCoord.xy/uRes,k=vec2(uRes.x/uRes.y,1.);
vec2 p=(uv-.5)*k,m=(uM-.5)*k,d=p-m;
float fo=exp(-dot(d,d)*5.),a=fo*(.35+uE*2.4);
p=m+mat2(cos(a),-sin(a),sin(a),cos(a))*d-uV*fo*.5;
float t=uT*.05;
vec2 s=p*1.35+vec2(0.,-t*2.6);
vec2 q=vec2(fbm(s+vec2(0.,t)),fbm(s+vec2(5.2,1.3)-t));
vec2 r=vec2(fbm(s+3.2*q+vec2(1.7,9.2)+t*1.4),fbm(s+3.2*q+vec2(8.3,2.8)-t*.9));
vec2 w=s+2.8*r;
float f=fbm(w),f2=fbm(w+vec2(.035,.055));
float col=smoothstep(1.15,-.25,uv.y+(r.x-.5)*.8);
float de=smoothstep(.42,.98,f*(.45+col)+.22*length(q));
float li=clamp((f-f2)*9.+.5,0.,1.);
float fi=exp(-abs(f-.56)*38.)*(.25+col);
vec3 bg=uBg*(1.2-uv.y*.6)+uB*.05*(1.-uv.y)*(1.-uv.y);
vec3 sm=mix(uA,uB,clamp(length(r)*1.1-.3,0.,1.)*de);
vec3 c=bg+sm*de*1.3+uL*li*li*de*.55+mix(uB,uL,de)*fi*.4;
c+=uB*pow(de,3.)*col*.5;
c+=uL*fo*uE*.12;
vec2 v=uv-.5;c*=1.-dot(v,v)*.9;
c=c/(1.+c*.35);
c=pow(c,vec3(.4545))+(h(gl_FragCoord.xy+fract(uT))-.5)*.012;
gl_FragColor=vec4(c,1.);
}`;
function fallback() {
root.classList.add("sin-webgl");
setCSS(PALETAS[0]);
}
let prog = null;
if (gl) {
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; };
const vs = sh(gl.VERTEX_SHADER, VERT), fs = sh(gl.FRAGMENT_SHADER, FRAG);
if (vs && fs) {
prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) prog = null;
}
}
if (!prog) { fallback(); wirePaletas(null); return; }
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "a");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const U = {};
["uRes", "uM", "uV", "uT", "uE", "uBg", "uA", "uB", "uL"].forEach((u) => (U[u] = gl.getUniformLocation(prog, u)));
// ---------- Tamaño: el humo es suave, se pinta a menos resolución y el navegador lo escala ----------
const DPR_MAX = tactil ? 1.5 : 2;
let calidad = tactil ? 0.5 : 0.62; // fracción de píxeles físicos que se pintan (baja sola si va lento)
function resize() {
const dpr = Math.min(devicePixelRatio || 1, DPR_MAX);
const w = Math.max(1, Math.round(canvas.clientWidth * dpr * calidad));
const hh = Math.max(1, Math.round(canvas.clientHeight * dpr * calidad));
if (canvas.width !== w || canvas.height !== hh) { canvas.width = w; canvas.height = hh; }
gl.viewport(0, 0, w, hh);
gl.uniform2f(U.uRes, w, hh);
}
new ResizeObserver(() => { resize(); if (!running) draw(); }).observe(canvas);
resize();
// ---------- Color con transición ----------
let actual = PALETAS[0], cur = {}, from = {}, to = {}, mezcla = 1;
["fondo", "humo", "brillo", "luz"].forEach((k) => { cur[k] = hex(actual[k]); to[k] = cur[k].slice(); });
function setPaleta(i) {
actual = PALETAS[i]; setCSS(actual);
["fondo", "humo", "brillo", "luz"].forEach((k) => { from[k] = cur[k].slice(); to[k] = hex(actual[k]); });
mezcla = reduce ? 1 : 0;
if (reduce) { for (const k in to) cur[k] = to[k].slice(); draw(); }
}
function stepColor(dt) {
if (mezcla >= 1) return;
mezcla = Math.min(1, mezcla + dt / 1.2);
const e = 1 - Math.pow(1 - mezcla, 3);
for (const k in to) cur[k] = from[k].map((v, j) => v + (to[k][j] - v) * e);
}
// ---------- Puntero (suavizado) + deriva automática cuando nadie lo toca ----------
const M = { x: 0.62, y: 0.42, tx: 0.62, ty: 0.42, vx: 0, vy: 0, e: 0, last: -1e9 };
let px = null, py = null;
function onMove(ev) {
const r = canvas.getBoundingClientRect();
const x = (ev.clientX - r.left) / r.width, y = 1 - (ev.clientY - r.top) / r.height;
if (px !== null) {
const ar = r.width / r.height;
M.vx += ((x - px) * ar * 22 - M.vx) * 0.35;
M.vy += ((y - py) * 22 - M.vy) * 0.35;
}
px = x; py = y; M.tx = x; M.ty = y; M.last = performance.now();
}
addEventListener("pointermove", onMove, { passive: true });
addEventListener("pointerdown", onMove, { passive: true });
document.addEventListener("pointerleave", () => { px = py = null; });
let t = 18, prev = performance.now(), running = false, visible = true, raf = 0;
function update(now, dt) {
if (now - M.last > 2600) { // deriva en ocho lento
const s = now * 0.00012;
M.tx = 0.5 + Math.sin(s * 2.1) * 0.26; M.ty = 0.45 + Math.sin(s * 3.3) * 0.16;
}
const kp = 1 - Math.pow(0.001, dt); // lerp independiente del framerate
const dx = M.tx - M.x, dy = M.ty - M.y;
M.x += dx * kp * 0.9; M.y += dy * kp * 0.9;
M.vx *= Math.pow(0.08, dt); M.vy *= Math.pow(0.08, dt);
const vmax = 1.6, vl = Math.hypot(M.vx, M.vy);
if (vl > vmax) { M.vx *= vmax / vl; M.vy *= vmax / vl; }
const objetivo = Math.min(1, vl * 0.9 + Math.hypot(dx, dy) * 2.5);
M.e += (objetivo - M.e) * (1 - Math.pow(0.02, dt));
}
function draw() {
gl.uniform1f(U.uT, t);
gl.uniform2f(U.uM, M.x, M.y);
gl.uniform2f(U.uV, M.vx, M.vy);
gl.uniform1f(U.uE, M.e);
gl.uniform3fv(U.uBg, cur.fondo); gl.uniform3fv(U.uA, cur.humo);
gl.uniform3fv(U.uB, cur.brillo); gl.uniform3fv(U.uL, cur.luz);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
// Calidad adaptativa: si la media pasa de 22 ms, pinta menos píxeles
let acc = 0, frames = 0;
function loop(now) {
raf = requestAnimationFrame(loop);
const dt = Math.min(0.05, (now - prev) / 1000); prev = now;
t += dt; update(now, dt); stepColor(dt); draw();
acc += dt; frames++;
if (frames === 90) {
if (acc / frames > 0.022 && calidad > 0.36) { calidad -= 0.08; resize(); }
acc = 0; frames = 0;
}
}
function start() { if (running || reduce || !visible || document.hidden) return; running = true; prev = performance.now(); raf = requestAnimationFrame(loop); }
function stop() { running = false; cancelAnimationFrame(raf); }
new IntersectionObserver((es) => { visible = es[0].isIntersecting; visible ? start() : stop(); }).observe(canvas);
document.addEventListener("visibilitychange", () => (document.hidden ? stop() : start()));
canvas.addEventListener("webglcontextlost", (e) => { e.preventDefault(); stop(); fallback(); });
draw();
requestAnimationFrame(() => root.classList.add("listo"));
if (reduce) { M.x = 0.62; M.y = 0.42; draw(); } else start();
wirePaletas(setPaleta);
// ---------- Selector de paleta (radiogroup con flechas y teclas 1–3) ----------
function wirePaletas(fn) {
const bs = [...document.querySelectorAll(".paletas button")];
const elegir = (i, foco) => {
bs.forEach((b, j) => { b.setAttribute("aria-checked", String(i === j)); b.tabIndex = i === j ? 0 : -1; });
if (foco) bs[i].focus();
fn ? fn(i) : setCSS(PALETAS[i]);
};
bs.forEach((b, i) => {
b.addEventListener("click", () => elegir(i));
b.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); elegir((i + 1) % bs.length, true); }
if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); elegir((i + bs.length - 1) % bs.length, true); }
});
});
addEventListener("keydown", (e) => { if (["1", "2", "3"].includes(e.key)) elegir(Number(e.key) - 1); });
}
})();
```
Puntos delicados del JS:
- **Triángulo único** `[-1,-1, 3,-1, -1,3]` que cubre la pantalla (sin diagonal visible).
- **Resolución reducida**: `canvas.width = clientWidth · min(devicePixelRatio, DPR_MAX) · calidad`. `DPR_MAX` es 2 (1,5 en táctil) y `calidad` empieza en 0,62 (0,5 en táctil); baja 0,08 cada 90 fotogramas si la media supera 22 ms, sin pasar de 0,36. El `ResizeObserver` recalcula y, si el bucle está parado, repinta.
- **Colores en lineal**: `hex()` devuelve `pow(canal/255, 2.2)`; la transición de paleta interpola esos valores, nunca el hex.
- **Lerp independiente del framerate**: `1 - Math.pow(base, dt)` con `dt` en segundos y tope de 0,05 s.
- **Velocidad**: en cada `pointermove`, la diferencia de posición × 22 (la x corregida por aspecto) entra con un filtro de 0,35.
- **Pausa**: `IntersectionObserver` sobre el canvas y `visibilitychange`; `webglcontextlost` pasa al fallback.
## Interacción
- **Ratón**: moverlo remueve y arrastra el humo; cuanto más rápido, más giro y más halo. Al salir de la ventana se olvida la última posición (sin saltos al volver).
- **Táctil**: `pointerdown` y `pointermove` con `touch-action: none` en el canvas; tocar y arrastrar hace lo mismo que el ratón. Sin tocar, la deriva en ocho mantiene el humo vivo.
- **Teclado**: el selector es un `radiogroup` con tabindex itinerante; las flechas cambian de paleta y mueven el foco; las teclas `1`, `2` y `3` eligen paleta desde cualquier punto de la página.
## Adaptación a móvil y tablet
- ≤ 640 px: la fila inferior pasa a columna (titular arriba, selector debajo, alineados a la izquierda, 20 px de separación), la línea superior baja a 10 px y los botones se estrechan (padding 0 11px 0 9px, tracking .08em).
- El titular escala con `clamp(46px, 8.4vw, 132px)`, `max-width: 9.5ch` y `text-wrap: balance`.
- Márgenes `--gutter: clamp(16px, 3.2vw, 48px)` y respeto de `safe-area-inset-bottom`.
- Alturas con `100dvh`/`100svh`; `body` sin scroll. Sin scroll horizontal de 360 a 1920 px.
- En táctil: DPR tope 1,5 y calidad 0,5.
## Accesibilidad y movimiento reducido
- El lienzo vive en `.escena` con `aria-hidden="true"`; el contenido real está en `<main>` y el titular es el `<h1>`.
- Contraste: texto `#f5efe8` sobre humo oscuro; los rótulos secundarios al 62 % de opacidad solo sobre las zonas oscuras de arriba.
- Foco visible: `outline: 2px solid var(--luz)` con 2 px de separación.
- `prefers-reduced-motion: reduce`: el bucle no arranca; se pinta **un solo fotograma** (t = 18, puntero en 0,62/0,42) y se anulan las transiciones CSS. Cambiar de paleta repinta ese fotograma al instante con los colores nuevos.
## Rendimiento
- Coste: 6 fbm × 5 octavas = 30 ruidos por píxel, sobre el ~38 % de los píxeles (0,62²) en escritorio. 60 fps en un portátil normal.
- DPR máximo 2 (1,5 en móvil), calidad adaptativa y bucle parado fuera de pantalla o con la pestaña oculta.
- **Fallback sin WebGL** (o si el shader no compila, o se pierde el contexto): clase `.sin-webgl`, se oculta el canvas y queda el degradado CSS de `.escena` con los colores de la paleta; el selector sigue funcionando y cambia esos colores.
- Peso: unos 15 KB de HTML en total.
## Personalización
- **Paleta principal**: `#0a0605` (fondo casi negro), `#4a120a` (sombra del humo, oscura y saturada), `#e0692c` (cuerpo, el color protagonista) y `#ffd9a8` (filos y filamentos, casi blanco teñido). Aparecen en `:root`, en `PALETAS[0]` y en el `style` del primer botón: cambia los tres sitios a la vez.
- **Las otras dos paletas**: edita `PALETAS[1]` y `PALETAS[2]` y el `style="--c1…--c3"` de su botón (y su nombre). Regla para que funcione: fondo con luminosidad < 4 %, humo ≈ 15–25 %, brillo ≈ 50–65 % y saturado, luz ≈ 92–97 %. Ejemplos: Absenta `#050805 / #1d3a12 / #9bd15a / #f3ffe0`; Coral `#0b0607 / #4d1624 / #ff6f61 / #ffe3d6`.
- **Densidad**: sube el primer umbral de `smoothstep(.42,.98, …)` para más negro; bájalo (p. ej. .30) para que el humo llene la pantalla.
- **Velocidad**: `float t=uT*.05;` (0,03 = más lento y solemne; 0,08 = más vivo). **Escala**: `p*1.35` (más alto = volutas más pequeñas).
- **Fuerza del puntero**: `.35+uE*2.4` (giro) y `uV*fo*.5` (empuje); el radio está en `exp(-dot(d,d)*5.)` (más alto = radio menor).
- **Filamentos**: `*38.` los afina (más alto) o los engorda; el `*.4` de la composición regula su brillo.
- **Titular**: `Lo que queda en el` más la palabra de acento `aire.` (peso 700, color de acento). Puedes quitar la capa entera y usar solo `.escena` como fondo de un hero: ponla en `position: absolute` dentro de la sección y observa esa sección con el `IntersectionObserver`.
## Comprobación final
1. Un único `index.html`, sin librerías; solo Google Fonts (Inter Tight y Geist Mono).
2. El humo sube despacio, denso abajo y deshilachado arriba, con zonas negras amplias.
3. Se ven filos iluminados y filamentos finos y brillantes que siguen las volutas.
4. Mover el puntero enrosca el humo a su alrededor y lo arrastra; al parar, se calma en torno a un segundo.
5. Sin tocar durante 2,6 s, el remolino se desplaza solo describiendo un ocho lento.
6. Brasa, Glaciar y Ceniza cambian con un fundido de 1,2 s; las flechas y las teclas 1–3 funcionan; el foco se ve.
7. Entrada: lienzo en 1,8 s, palabras del titular escalonadas con desenfoque, selector a los 0,9 s.
8. A 390 px el titular y el selector se apilan abajo a la izquierda sin desbordar; no hay scroll horizontal.
9. Con movimiento reducido: una imagen fija bonita y cambio de paleta instantáneo.
10. Sin WebGL: degradado CSS con los mismos colores y la capa de texto intacta.
11. El bucle se para con la pestaña oculta o fuera de pantalla; DPR ≤ 2 (1,5 en táctil).
12. Cero errores en consola y 60 fps en un portátil normal.Prueba en frío
Aún sin publicar para esta pieza
En la prueba en frío pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos lo que genera con la demo. Cuando esté hecha, aquí verás la captura, el veredicto y la fecha.
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