Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

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.

Prompt
≈8.358 tokens · con Lite
Librerías
ninguna
Demo
15,2 KB · ligero
Prueba en frío
pendiente

Antes de copiar

Tu marca, dentro del prompt

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.

Colores

Sin cambios: se copia con los valores de la demo.

Gratis y entero

El prompt, entero y a la vista

Qué trae el prompt, por apartados

  1. 01Qué es
  2. 02Librerías y esqueleto
  3. 03Tokens de diseño
  4. 04Estructura y contenido
  5. 05Movimiento
  6. 06Código clave
  7. 07Interacción
  8. 08Adaptación a móvil y tablet
  9. 09Accesibilidad y movimiento reducido
  10. 10Rendimiento
  11. 11Personalización
  12. 12Comprobación final
Completo
4.088 palabras · ≈8.358 tokens
Lite
946 palabras · ≈2.017 tokens
Librerías
ninguna
Peso de la demo
15,2 KB (ligero)
Resolución máxima (DPR)
2
Se pausa fuera de pantalla
sí
Respeta «menos movimiento»
sí
Alternativa sin WebGL
sí

Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.

prompt.md
# 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

La 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

Cómo usarlo en 4 pasos

  1. 01

    Personaliza

    Rellena el formulario de arriba con el nombre, los colores y los textos de tu cliente. Se meten en el prompt al copiar.
  2. 02

    Copia el Completo o el Lite

    El Completo, para Claude, ChatGPT, Gemini o Cursor. El Lite, más corto, para Lovable, Bolt o v0, que aceptan menos texto.
  3. 03

    Pega e itera

    Pégalo en una conversación nueva. Si quieres cambiar algo, pídelo con una frase corta:
    • «Que el humo llene toda la pantalla, sin zonas negras»
    • «Hazlo más lento y solemne, casi quieto»
    • «Cambia la paleta principal a verde absenta»
    • «Que el puntero lo remueva con más fuerza y en un radio mayor»
    • «Úsalo solo como fondo del hero, sin titular ni selector»
  4. 04

    Publica

    Guarda el resultado como HTML (o en tu proyecto) y súbelo a Vercel, Netlify o tu hosting de siempre. Tienes los pasos en la guía.

Guías por herramientaClaudeChatGPTGeminiCursorLovableBoltv0

Licencia en claro

Qué puedes hacer con esta pieza

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

  • Usarlo en tus webs y en las de tus clientes, también si cobras por ellas.
  • Cambiarlo todo lo que quieras.
  • Publicar el resultado sin citarnos.

No puedes

  • Revender o regalar el prompt, la demo o el código como plantilla o biblioteca.
  • Compartir tu cuenta Pro.

Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.

Preguntas frecuentes

Lo que suele preguntarse

¿Puedo poner mi propio texto encima?
Sí. El humo es denso abajo y deja zonas negras arriba; el titular va en una capa HTML aparte, así que puedes sustituirla por tu hero, un botón o un formulario sin tocar el shader.
¿Consume mucha batería o va lento en móvil?
Se pinta a la mitad de la resolución en táctil, limita el DPR a 1,5, baja la calidad sola si un fotograma tarda más de 22 ms y se detiene cuando sale de pantalla o la pestaña está oculta.
¿Cómo pongo los colores de mi marca?
Cada paleta son cuatro colores: fondo, sombra, color principal y luz de los filos. Cambia los cuatro de la primera paleta y el resto se adapta; el prompt da la regla de luminosidad para que siga pareciendo humo.
¿Qué pasa si el navegador no tiene WebGL?
Se muestra un degradado CSS con los mismos colores de la paleta y el texto sigue intacto; el selector también cambia ese degradado.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈8358 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2017 tokens) resume lo esencial para herramientas que aceptan menos texto, como Lovable, Bolt o v0.

Versión 1.0 · publicada el 30-09-2026

Cópialo desde aquí

Tu navegador no nos deja escribir en el portapapeles. El texto ya está seleccionado: pulsa Ctrl + C (o ⌘ + C) o el botón.