Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

Una pantalla de carga que va de la noche al alba. Un contador de rodillos enorme avanza de 000 a 100 sumando tareas reales (el script, las tipografías, el dibujo de la portada y el evento de carga), nunca más rápido que una duración mínima, y al mismo compás el logotipo lineal se escribe trazo a trazo con una pluma naranja mientras un resplandor sube desde el horizonte. Al llegar a 100, seis franjas caen de izquierda a derecha y el logotipo vuela hasta su sitio en el menú mientras la portada entra por líneas. Los enlaces internos reutilizan la cortina como transición de página. Se salta con Esc y no aparece con movimiento reducido. Encaja en hoteles, restaurantes, estudios y portfolios que quieran una primera impresión de marca; funciona igual en azul noche, verde botella o negro carbón.

Prompt
≈14.976 tokens · con Lite
Librerías
gsap
Demo
35,1 KB · ligero
Prueba en frío
9,8/10 · 30-09-2026

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
6.597 palabras · ≈14.976 tokens
Lite
1.437 palabras · ≈2.713 tokens
Librerías
gsap 3.15.0
Peso de la demo
35,1 KB (ligero)
Resolución máxima (DPR)
2
Se pausa fuera de pantalla
sí
Respeta «menos movimiento»
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: Preloader con contador y cortina

Un preloader de amanecer: un contador de rodillos de 000 a 100 que avanza al ritmo de la carga real, el logotipo que se escribe a mano alzada al mismo compás y una cortina de franjas que cae y deja ver la web mientras el logotipo vuela a su sitio en el menú.

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 pantalla de carga para la web de Casa Alba, una casa de huéspedes en Arvale, y la portada que aparece detrás. La idea es «de la noche al alba»: mientras la página carga, el fondo es azul noche y desde el borde inferior sube un resplandor anaranjado proporcional al progreso; al llegar a 100 la cortina cae en franjas y aparece la web, clara y cálida como la cal.

Tres cosas avanzan juntas y con el mismo número (el progreso mostrado, de 0 a 100):

1. **Contador de rodillos** abajo a la izquierda, enorme, en Instrument Serif: tres columnas de cifras que ruedan verticalmente (odómetro). Los ceros a la izquierda se ven apagados (opacidad 0,2): «007», «042», «100».
2. **Logotipo que se escribe**: el monograma lineal «alba.» dibujado con `stroke-dashoffset`, trazo a trazo en orden de escritura, con una pluma (un punto color alba) en la punta del trazo. El punto final del logotipo aparece con un pequeño rebote al llegar a 100.
3. **Horizonte y amanecer**: una línea de 1 px a lo ancho que se llena de izquierda a derecha (`scaleX`) y un resplandor radial que sube desde abajo y gana opacidad.

**Sincronizado con la carga real**: el progreso no es un temporizador falso. Se suman cuatro tareas reales con peso: el script de GSAP (15), las tipografías de Google Fonts (30, una cuarta parte por cada cara), el dibujo del paisaje de la portada en un canvas en 14 pasos (40) y el evento `load` de la ventana (15). El número mostrado persigue ese progreso real pero nunca adelanta a una curva de tiempo mínima (`4200` ms desde que empezó la navegación, con easing `power2.inOut`), de modo que en conexiones rápidas el contador no pega un salto a 100 y en conexiones lentas espera de verdad. Un seguro de 9 000 ms da la página por cargada si algo se atasca. Abajo a la derecha, una lista de las cuatro tareas («Estructura», «Tipografías», «Paisaje», «Últimos detalles») marca cada una como «Listo» cuando su tarea real ha terminado y el contador ya ha pasado su umbral.

**Salida** (a 100): pausa de 450 ms con el punto del logotipo rebotando; las cifras suben y desaparecen tras su máscara; el resto de la interfaz se funde; el resplandor se apaga; las seis franjas de la cortina caen hacia abajo una tras otra (de izquierda a derecha) con una fina línea color alba en su borde superior; a la vez, el logotipo grande vuela desde el centro hasta el hueco del logotipo del menú, encogiéndose y pasando de color crema a tinta. Detrás, la portada hace su entrada (titular por líneas que suben desde su máscara, foto que se abre con `clip-path` y se asienta desde escala 1,22, resto de elementos con fundido y subida de 16 px).

**Cambio de página**: los enlaces internos (menú, «Reservar», «Ver las habitaciones») no navegan: la cortina baja desde arriba, el contador corre otra vez más rápido (2 000 ms, la «página nueva» ya está en caché) y la cortina vuelve a abrirse. En la demo hay además un botón «Repetir la carga» y un interruptor «Repetición automática» (activo por defecto) que repite todo el ciclo cada 2 800 ms de web a la vista.

**Se salta** con el botón «Saltar intro» o con la tecla Esc, y **no aparece** si el usuario prefiere menos movimiento.

## Librerías y esqueleto

- GSAP `3.15.0`, solo el núcleo, cargado con `async` para que el contador empiece antes de que llegue:
  `<script id="gsap-js" src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js" async></script>` justo antes del script principal, al final del `<body>`.
- Nada más: el contador corre con `requestAnimationFrame` propio (no con `gsap.ticker`), así funciona aunque GSAP aún no haya cargado. GSAP solo se usa para las líneas de tiempo de salida, cierre y entrada.
- Fuentes de Google (tienen ñ, tildes, ¿ y ¡): Instrument Serif (400 normal y cursiva) para el display y Hanken Grotesk (400, 500, 600) para el texto. La hoja se carga **sin bloquear el render** (así el preloader se pinta al instante) y lleva `id` porque la tarea «Tipografías» espera a su evento `load`.

Cabecera exacta:

```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Casa Alba · Casa de huéspedes en Arvale</title>
<meta name="description" content="Preloader con contador sincronizado con la carga real, logotipo que se escribe y cortina que se abre.">
<script>document.documentElement.classList.add("js","precarga")</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link id="hoja-fuentes" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<style>
  /* bloque :root de «Tokens de diseño» + CSS completo de «Código clave» */
</style>
</head>
```

## Tokens de diseño

```css
:root{
  --cortina:#12141b;
  --fondo:#efe8dc;
  --tinta:#1a1712;
  --tinta-suave:#5b544a;
  --acento:#a3431f;
  --alba:#e8743f;
  --linea:rgba(26,23,18,.14);
  --f-display:"Instrument Serif","Times New Roman",serif;
  --f-texto:"Hanken Grotesk",system-ui,-apple-system,"Segoe UI",sans-serif;
  --pad:clamp(16px,3.2vw,48px);
  --ease-out:cubic-bezier(.16,1,.3,1);
  --grano:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
}
```

Colores fuera de tokens (literales, derivados de los anteriores): líneas finas de la web `rgba(26,23,18,.14)` y bordes de botones de la demo `rgba(26,23,18,.3)`; textos secundarios del preloader `rgba(239,232,220,.66)` y `.62`; pista del horizonte `rgba(239,232,220,.16)`; separadores de la lista de tareas `rgba(239,232,220,.12)`; fondo del hueco de la foto `#d9b394`; rótulos sobre la foto: texto `#fff8ee` sobre `rgba(26,23,18,.62)`.

## Estructura y contenido

Orden del `<body>` (todos los textos son literales):

```
body
├─ div.pre#pre                       preloader, fixed a pantalla completa, z-index 50
│  ├─ div.pre__cortina (aria-hidden)  6 <span> en fila (flex), cada uno una franja color cortina
│  ├─ div.pre__atmos (aria-hidden)    .pre__alba (resplandor) + .pre__grano (grano SVG)
│  ├─ div.pre__ui                     rejilla de 3 filas: arriba · hueco · abajo
│  │  ├─ .pre__arriba   «Casa Alba» + «Casa de huéspedes en Arvale» · «47° 12′ N · 8° 25′ E» · botón «Saltar intro [Esc]»
│  │  └─ .pre__abajo    contador «000 %» · caja de tareas («Preparando la casa» + 4 tareas) · línea de horizonte
│  └─ div.pre__logo-caja (aria-hidden) SVG «alba.» grande, centrado
├─ p.sr#estado (role=status)          anuncios para lectores de pantalla
└─ div.sitio#sitio
   ├─ header.nav      logotipo «alba.» pequeño · menú (Habitaciones, La casa, Desayunos, Cómo llegar) · «Reservar →»
   └─ main.hero#inicio  rejilla 7fr/5fr
      ├─ .hero__texto   antetítulo · h1 «Despertar / sobre el Cañón.» · entradilla · botones
      ├─ figure.marco   canvas con el paisaje · «Hab. 04 · Balcón al Cañón» · «Foto: balcón al amanecer sobre el Cañón»
      └─ .hero__pie     datos (Desde · Teléfono · Escríbenos) · fila «Demostración» con dos botones
```

HTML literal del `<body>` (el script va en «Código clave»):

```html
<body>
<div class="pre" id="pre">
  <div class="pre__cortina" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span></div>
  <div class="pre__atmos" aria-hidden="true"><div class="pre__alba"></div><div class="pre__grano"></div></div>
  <div class="pre__ui">
    <div class="pre__arriba">
      <p class="pre__marca" aria-hidden="true">Casa Alba<small>Casa de huéspedes en Arvale</small></p>
      <p class="pre__coord" aria-hidden="true">47° 12′ N · 8° 25′ E</p>
      <button class="pre__saltar" type="button" id="saltar">Saltar intro <kbd>Esc</kbd></button>
    </div>
    <div></div>
    <div class="pre__abajo" aria-hidden="true">
      <div class="contador-mascara"><div class="contador"><span class="digito"></span><span class="digito"></span><span class="digito"></span><span class="contador__pct">%</span></div></div>
      <div class="tareas-caja">
        <p class="tareas__titulo">Preparando la casa</p>
        <ol class="tareas">
          <li data-t="estructura">Estructura</li>
          <li data-t="fuentes">Tipografías</li>
          <li data-t="paisaje">Paisaje</li>
          <li data-t="detalles">Últimos detalles</li>
        </ol>
      </div>
      <div class="pre__horizonte"></div>
    </div>
  </div>
  <div class="pre__logo-caja" aria-hidden="true">
    <svg class="pre__logo" viewBox="-8 -8 278 116" fill="none" stroke="currentColor" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
      <path class="logo__trazo" d="M60 70A30 30 0 1 0 0 70A30 30 0 1 0 60 70"/>
      <path class="logo__trazo" d="M60 40V100"/>
      <path class="logo__trazo" d="M82 0V100"/>
      <path class="logo__trazo" d="M104 0V100"/>
      <path class="logo__trazo" d="M104 70A30 30 0 1 1 164 70A30 30 0 1 1 104 70"/>
      <path class="logo__trazo" d="M238 70A30 30 0 1 0 178 70A30 30 0 1 0 238 70"/>
      <path class="logo__trazo" d="M238 40V100"/>
      <circle class="logo__punto" cx="258" cy="96" r="6" fill="currentColor" stroke="none"/>
      <circle class="logo__pluma" r="5.5" stroke="none"/>
    </svg>
  </div>
</div>

<p class="sr" role="status" id="estado"></p>

<div class="sitio" id="sitio">
  <header class="nav">
    <a class="nav__marca" href="#inicio" data-pagina="Inicio" aria-label="Casa Alba, inicio">
      <svg class="nav__logo" viewBox="-8 -8 278 116" fill="none" stroke="currentColor" stroke-width="7" stroke-linecap="round" aria-hidden="true">
        <path d="M60 70A30 30 0 1 0 0 70A30 30 0 1 0 60 70M60 40V100M82 0V100M104 0V100M104 70A30 30 0 1 1 164 70A30 30 0 1 1 104 70M238 70A30 30 0 1 0 178 70A30 30 0 1 0 238 70M238 40V100"/>
        <circle cx="258" cy="96" r="6" fill="currentColor" stroke="none"/>
      </svg>
    </a>
    <nav aria-label="Principal">
      <ul>
        <li><a class="enlace aparece" href="#habitaciones" data-pagina="Habitaciones">Habitaciones</a></li>
        <li><a class="enlace aparece" href="#la-casa" data-pagina="La casa">La casa</a></li>
        <li><a class="enlace aparece" href="#desayunos" data-pagina="Desayunos">Desayunos</a></li>
        <li><a class="enlace aparece" href="#como-llegar" data-pagina="Cómo llegar">Cómo llegar</a></li>
      </ul>
    </nav>
    <a class="nav__reservar aparece" href="#reservar" data-pagina="Reservar">Reservar <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M2 8h11M9 4l4 4-4 4"/></svg></a>
  </header>

  <main class="hero" id="inicio">
    <div class="hero__texto">
      <p class="antetitulo aparece">Casa de huéspedes · Arvale · Valle Alto</p>
      <h1 class="hero__titulo" id="titulo" tabindex="-1"><span class="hero__linea"><span>Despertar</span></span><span class="hero__linea"><span>sobre el <em>Cañón</em>.</span></span></h1>
      <p class="hero__entradilla aparece">Seis habitaciones encaladas al borde del barranco. Desayuno en la terraza cuando el sol asoma por las cumbres y el Puente Alto aún está en sombra.</p>
      <div class="acciones aparece">
        <a class="boton" href="#reservar" data-pagina="Reservar">Reservar estancia <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M2 8h11M9 4l4 4-4 4"/></svg></a>
        <a class="enlace" href="#habitaciones" data-pagina="Habitaciones">Ver las habitaciones</a>
      </div>
    </div>

    <figure class="marco" aria-label="Ilustración: amanecer sobre el Cañón de Arvale visto desde un balcón">
      <canvas aria-hidden="true"></canvas>
      <p class="marco__numero">Hab. 04 · Balcón al Cañón</p>
      <figcaption class="marco__rotulo">Foto: balcón al amanecer sobre el Cañón</figcaption>
    </figure>

    <div class="hero__pie">
      <dl class="datos aparece">
        <div><dt>Desde</dt><dd>145 € la noche, impuestos incl.</dd></div>
        <div><dt>Teléfono</dt><dd><a href="tel:+00 000 000 000">+00 000 000 000</a></dd></div>
        <div><dt>Escríbenos</dt><dd><a href="mailto:hola@casaalba.com">hola@casaalba.com</a></dd></div>
      </dl>
      <div class="demo aparece">
        <span class="demo__rotulo">Demostración</span>
        <button type="button" id="repetir"><svg class="demo__icono" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M13.5 8A5.5 5.5 0 1 1 11.9 4.1M12.5 1.5v3h-3"/></svg>Repetir la carga</button>
        <button type="button" id="bucle" aria-pressed="true"><span class="interruptor" aria-hidden="true"></span>Repetición automática</button>
      </div>
    </div>
  </main>
</div>

<script id="gsap-js" src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js" async></script>
<script>
  /* JavaScript completo de «Código clave» */
</script>
</body>
</html>
```

Notas del marcado:
- El logotipo del preloader tiene **siete** `<path class="logo__trazo">` separados (en orden de escritura: panza de la primera a, asta de la a, l, asta de la b, panza de la b, panza de la segunda a, asta de la a), el `circle.logo__punto` (el punto final) y el `circle.logo__pluma` (la punta que escribe). El del menú usa un solo `<path>` con los mismos trazos concatenados y el mismo `viewBox="-8 -8 278 116"` y `stroke-width="7"`, para que al volar encaje al píxel.
- Construcción del monograma, por si hay que adaptarlo: altura de x 60 (de y=40 a y=100), ascendentes desde y=0, panzas circulares de radio 30 centradas en y=70, trazo de 7 con extremos redondos. a: panza de x=0 a 60 + asta en x=60; l en x=82; b: asta en x=104 + panza de 104 a 164; a: panza de 178 a 238 + asta en x=238; punto de r=6 en (258, 96).
- Los spans de las cifras se rellenan por JS (11 cifras por columna: 0–9 y un 0 extra al final).

## Movimiento

Progreso mostrado `v` (0–100) en cada fotograma: `tope = 100·power2.inOut(t)` con `t = (ahora − origen) / duración`; `objetivo = min(progresoReal, tope)`; `v += (objetivo − v)·(1 − e^(−7·dt))`; si `objetivo ≥ 100` y `v > 99,5` → `v = 100`. Al saltar: objetivo 100 y constante 14 en vez de 7; la salida se reproduce a `timeScale(1.5)`.

| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Columna de centenas y decenas (`.tira`) | `translateY(calc(var(--d) * -1em))` | cifra anterior → nueva | 0,45 s | `cubic-bezier(.16,1,.3,1)` (transición CSS) | — | cambio de entero de `v` |
| Columna de unidades | igual | salto seco (sin transición) | 0 | — | — | cambio de entero; en 100 lleva la misma transición de 0,45 s (clase `.final`) |
| Cifra apagada (`.digito.apagado`) | opacity | 1 ↔ 0,2 | 0,5 s | CSS por defecto | — | ceros a la izquierda |
| Trazos del logotipo | `stroke-dashoffset` | longitud → 0, trazo a trazo | continuo | ligado a `v` | — | cada fotograma |
| Pluma (`.logo__pluma`) | `cx`,`cy` | punta del trazo activo (`getPointAtLength`) | continuo | — | — | cada fotograma |
| Horizonte (`::after`) | `scaleX` | 0 → 1 (`var(--p)`) | continuo | — | — | cada fotograma |
| Resplandor (`.pre__alba`) | opacity · translateY | `p·.92` · `(1−p)·38%` | continuo | — | — | cada fotograma (`--p = v/100`) |
| Tareas | color + punto relleno + texto «En cola / Cargando / Listo» | — | 0,4 s | CSS | — | tarea real hecha y `v ≥` umbral (15/45/85/100) |
| **Salida** (línea de tiempo, `0` = llegada a 100) | | | | | | |
| Punto del logotipo | opacity 1, scale | 0 → 1 | 0,55 s | `back.out(2.6)` | 0 | 100 |
| Pluma | opacity | 1 → 0 | 0,2 s | por defecto | 0 | 100 |
| Contador | yPercent | 0 → −104 | 0,75 s | `power3.in` | 0,45 s («sale») | — |
| Arriba, tareas y horizonte | autoAlpha | 1 → 0 | 0,45 s | `power2.in` | sale | — |
| Atmósfera (resplandor + grano) | opacity | 1 → 0 | 0,7 s | `power2.inOut` | sale + 0,25 | — |
| Franjas de la cortina | yPercent | 0 → 101 (caen) | 1,15 s | `expo.inOut`, stagger 0,06 desde el inicio | sale + 0,35 | se añade `.pre--abre` (línea alba de 2 px en su borde superior) |
| Logotipo grande | x, y, scale (origen `0 0`) | centro → rect del logotipo del menú | 1,25 s | `expo.inOut` | sale + 0,3 | — |
| Logotipo grande | color | crema → color computado del logo del menú | 0,5 s | `power1.inOut` | sale + 0,75 | — |
| Web | `inert` fuera, clase `precarga` fuera, anuncio | — | — | — | sale + 0,9 | — |
| Titular (dos líneas) | yPercent | 112 → 0 | 1,25 s | `expo.out`, stagger 0,1 | sale + 1 | — |
| Marco de la foto | clip-path | `inset(10% 12% 10% 12%)` → `inset(0% 0% 0% 0%)` | 1,5 s | `expo.out` | sale + 1 | — |
| Canvas del paisaje | scale | 1,22 → 1 | 2 s | `expo.out` | sale + 1 | — |
| `.aparece` (menú, antetítulo, entradilla, botones, datos, demo) | autoAlpha, y | 0, 16 px → 1, 0 | 0,9 s | `power3.out`, stagger 0,05 | sale + 1,2 | — |
| **Cierre** (cambio de página) | | | | | | |
| Franjas | yPercent | −101 → 0 (bajan desde arriba) | 0,95 s | `expo.inOut`, stagger 0,06 desde el inicio | 0 | clic en enlace interno, «Repetir la carga» o bucle |
| Atmósfera | opacity | 0 → 1 | 0,3 s | por defecto | al cerrar − 0,15 | — |
| Arriba, tareas y horizonte | autoAlpha | 0 → 1 | 0,45 s | `power2.out` | − 0,2 | — |
| Contador de nuevo | — | 0 → 100 con duración 2 000 ms y progreso real = 100 | — | — | — | al terminar el cierre |

## Código clave

### CSS completo (va después del bloque `:root`)

```css
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;background:var(--fondo);color:var(--tinta)}
body{font-family:var(--f-texto);font-size:16px;line-height:1.5;min-height:100svh;overflow-x:hidden;-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;inset:0;background-image:var(--grano);opacity:.06;pointer-events:none;z-index:0}
.precarga body{overflow:hidden}
[hidden]{display:none!important}
a{color:inherit}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
:focus-visible{outline:2px solid var(--acento);outline-offset:3px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}

/* ---------- Sitio ---------- */
.sitio{position:relative;z-index:1;min-height:100svh;display:grid;grid-template-rows:auto 1fr;padding:0 var(--pad)}
.nav{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px 0 20px;border-bottom:1px solid var(--linea)}
.nav__marca{display:block;line-height:0;text-decoration:none}
.nav__logo{display:block;width:78px;height:auto;aspect-ratio:278/116;color:var(--tinta);overflow:visible}
.js .nav__logo{opacity:0}
.nav ul{display:flex;gap:clamp(18px,2.6vw,40px);margin:0;padding:0;list-style:none}
.enlace{position:relative;font-size:14px;font-weight:500;text-decoration:none;padding:4px 0;background:linear-gradient(currentColor,currentColor) left bottom/0 1px no-repeat;transition:background-size .5s var(--ease-out)}
.enlace:hover,.enlace[aria-current="page"]{background-size:100% 1px}
.nav__reservar{display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:600;text-decoration:none}
.nav__reservar svg,.boton svg{width:14px;height:14px;transition:transform .5s var(--ease-out)}
.nav__reservar:hover svg,.boton:hover svg{transform:translateX(4px)}

.hero{display:grid;grid-template-columns:minmax(0,7fr) minmax(0,5fr);grid-template-rows:1fr auto;grid-template-areas:"texto marco" "pie marco";column-gap:clamp(24px,4.5vw,80px);padding:clamp(24px,5vh,56px) 0 var(--pad)}
.hero__texto{grid-area:texto;align-self:end}
.antetitulo{display:flex;align-items:center;gap:12px;margin:0;font-size:12px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--acento)}
.antetitulo::before{content:"";width:28px;height:1px;background:currentColor}
.hero__titulo{margin:.18em 0 .22em;font-family:var(--f-display);font-weight:400;font-size:clamp(3.4rem,8.6vw,9.6rem);line-height:.92;letter-spacing:-.022em}
.hero__titulo:focus{outline:none}
.hero__linea{display:block;overflow:hidden;padding:0 .04em .1em;margin:0 -.04em -.1em}
.hero__linea>span{display:block}
.hero__titulo em{font-style:italic;color:var(--acento)}
.hero__entradilla{max-width:34ch;margin:0 0 28px;font-size:clamp(16px,1.25vw,19px);line-height:1.55;color:var(--tinta-suave)}
.acciones{display:flex;flex-wrap:wrap;align-items:center;gap:14px 28px}
.boton{display:inline-flex;align-items:center;gap:12px;padding:16px 22px;background:var(--tinta);color:var(--fondo);font-size:15px;font-weight:500;text-decoration:none;transition:background-color .3s}
.boton:hover{background:var(--acento)}
.hero__pie{grid-area:pie;align-self:end;margin-top:clamp(28px,5vh,56px)}
.datos{display:grid;grid-template-columns:repeat(3,minmax(0,auto));justify-content:start;gap:0;margin:0;border-top:1px solid var(--linea)}
.datos div{padding:14px 28px 0 0;margin-right:28px;border-right:1px solid var(--linea)}
.datos div:last-child{border:0;margin:0}
.datos dt{font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.datos dd{margin:4px 0 0;font-size:15px}
.datos a{text-decoration:none}
.datos a:hover{text-decoration:underline}
.demo{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;margin-top:22px;font-size:13px}
.demo__rotulo{font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.demo button{display:inline-flex;align-items:center;gap:9px;min-height:36px;padding:0 14px;border:1px solid rgba(26,23,18,.3);font-weight:500;transition:border-color .3s,background-color .3s}
.demo button:hover{border-color:var(--tinta);background:rgba(26,23,18,.04)}
.demo__icono{width:14px;height:14px}
.interruptor{position:relative;width:28px;height:16px;border-radius:8px;background:rgba(26,23,18,.22);transition:background-color .3s}
.interruptor::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--fondo);transition:transform .4s var(--ease-out)}
[aria-pressed="true"] .interruptor{background:var(--acento)}
[aria-pressed="true"] .interruptor::after{transform:translateX(12px)}

.marco{grid-area:marco;position:relative;align-self:end;width:100%;aspect-ratio:4/5;max-height:calc(100svh - 170px);margin-left:auto;overflow:hidden;background:#d9b394}
.marco canvas{position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:cover;transform-origin:62% 55%}
.marco__rotulo,.marco__numero{position:absolute;z-index:1;margin:0;font-size:11px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:#fff8ee;background:rgba(26,23,18,.62);padding:6px 9px}
.marco__rotulo{left:14px;bottom:14px}
.marco__numero{right:14px;top:14px}

/* ---------- Preloader ---------- */
.pre{position:fixed;inset:0;z-index:50;display:none;color:var(--fondo);--p:0;animation:seguro 0s 12s forwards}
.js .pre{display:block}
@keyframes seguro{to{visibility:hidden}}
.pre__cortina{position:absolute;inset:0;display:flex;overflow:hidden}
.pre__cortina span{position:relative;flex:1 1 0;margin-right:-1px;background:var(--cortina);will-change:transform}
.pre__cortina span::before{content:"";position:absolute;left:0;right:0;top:0;height:2px;background:var(--alba);opacity:0;transition:opacity .3s}
.pre--abre .pre__cortina span::before{opacity:1}
.pre__atmos{position:absolute;inset:0;pointer-events:none}
.pre__alba{position:absolute;inset:0;background:radial-gradient(95% 62% at 50% 100%,color-mix(in srgb,var(--alba) 62%,transparent),color-mix(in srgb,var(--alba) 20%,transparent) 46%,transparent 76%);opacity:calc(var(--p) * .92);transform:translateY(calc((1 - var(--p)) * 38%))}
.pre__grano{position:absolute;inset:0;background-image:var(--grano);opacity:.1}
.pre__ui{position:absolute;inset:0;display:grid;grid-template-rows:auto 1fr auto;padding:var(--pad)}
.pre__arriba{display:grid;grid-template-columns:1fr auto 1fr;align-items:start;gap:16px}
.pre__marca{margin:0;font-family:var(--f-display);font-size:24px;line-height:1}
.pre__marca small{display:block;margin-top:8px;font-family:var(--f-texto);font-size:11px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:rgba(239,232,220,.66)}
.pre__coord{margin:0;font-size:11px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:rgba(239,232,220,.66);font-variant-numeric:tabular-nums}
.pre__saltar{justify-self:end;display:inline-flex;align-items:center;gap:10px;min-height:36px;padding:0 4px;font-size:12px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;pointer-events:auto}
.pre__saltar kbd{font:inherit;font-size:10px;padding:3px 6px;border:1px solid rgba(239,232,220,.4);border-radius:3px;color:rgba(239,232,220,.8)}
.pre__saltar:hover kbd{border-color:var(--fondo)}
.pre :focus-visible{outline-color:var(--fondo)}
.pre__abajo{position:relative;display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding-bottom:18px}
.pre__horizonte{position:absolute;left:0;right:0;bottom:0;height:1px;background:rgba(239,232,220,.16)}
.pre__horizonte::after{content:"";position:absolute;inset:0;background:var(--fondo);transform:scaleX(var(--p));transform-origin:left}
.contador-mascara{overflow:hidden;margin-bottom:-.06em}
.contador{display:flex;align-items:flex-start;font-family:var(--f-display);font-size:clamp(7rem,19vw,17.5rem);line-height:1;letter-spacing:-.02em}
.digito{display:block;width:.5em;height:1em;overflow:hidden;text-align:center;transition:opacity .5s}
.digito.apagado{opacity:.2}
.tira{display:block;transform:translateY(calc(var(--d,0) * -1em));transition:transform .45s var(--ease-out)}
.digito:nth-child(3) .tira{transition:none}
.contador.final .digito:nth-child(3) .tira{transition:transform .45s var(--ease-out)}
.sin-trans .tira{transition:none}
.tira span{display:block;height:1em}
.contador__pct{margin:.14em 0 0 .08em;font-size:.2em;font-style:italic;color:rgba(239,232,220,.66)}
.tareas{margin:0;padding:0;list-style:none;min-width:230px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase}
.tareas__titulo{margin:0 0 10px;font-size:11px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:rgba(239,232,220,.66)}
.tareas li{display:flex;align-items:center;gap:12px;padding:7px 0;border-top:1px solid rgba(239,232,220,.12);color:rgba(239,232,220,.62);transition:color .4s}
.tareas li::before{content:"";width:7px;height:7px;border-radius:50%;border:1px solid currentColor;transition:background-color .4s,border-color .4s}
.tareas li::after{content:"En cola";margin-left:auto;font-weight:500}
.tareas li.activa{color:var(--fondo)}
.tareas li.activa::after{content:"Cargando"}
.tareas li.hecha{color:var(--fondo)}
.tareas li.hecha::before{background:var(--alba);border-color:var(--alba)}
.tareas li.hecha::after{content:"Listo"}
.pre__logo-caja{position:absolute;inset:0;display:grid;place-items:center;padding-bottom:9vh;pointer-events:none}
.pre__logo{display:block;width:clamp(210px,29vw,440px);height:auto;aspect-ratio:278/116;overflow:visible;color:var(--fondo);will-change:transform}
.logo__pluma{fill:var(--alba);opacity:0}
.logo__punto{opacity:0}
.pre.estatico{display:block!important;animation:none;opacity:0;transition:opacity .25s}

@media (max-width:900px){
  .hero{grid-template-columns:1fr;grid-template-rows:auto;grid-template-areas:"texto" "marco" "pie";padding-top:28px}
  .hero__titulo{font-size:clamp(3.3rem,14.5vw,7rem)}
  .marco{aspect-ratio:4/3;max-height:none;margin-top:36px}
  .nav ul{display:none}
  .hero__pie{margin-top:24px}
}
@media (max-width:600px){
  .nav__logo{width:66px}
  .datos{grid-template-columns:1fr 1fr}
  .datos div{margin-right:16px;padding-right:16px}
  .datos div:nth-child(2){border:0;margin:0}
  .datos div:last-child{grid-column:1/-1;padding-top:12px}
  .pre__coord,.tareas-caja{display:none}
  .pre__arriba{grid-template-columns:1fr auto}
  .pre__cortina span:nth-child(n+5){display:none}
  .contador{font-size:36vw}
  .pre__logo{width:64vw}
  .pre__logo-caja{padding-bottom:14vh}
}
@media (prefers-reduced-motion:reduce){
  .js .pre{display:none}
  .js .nav__logo{opacity:1}
  *,*::before,*::after{transition-duration:.01ms!important}
}
```

### JavaScript completo

Va en un `<script>` normal justo después del de GSAP. Todo dentro de una función autoejecutable.

```js
(() => {
  "use strict";
  // ---------- Ajustes ----------
  const NOMBRE = "Casa Alba";
  const DURACION_MINIMA = 4200;     // ms desde que empieza a cargar la página
  const DURACION_NAVEGACION = 2000; // ms en los cambios de página (ya hay caché)
  const DURACION_MAXIMA = 9000;     // seguro: pasado este tiempo, se da por cargada
  const PAUSA_EN_100 = 450;         // ms quieto en 100 antes de abrir
  const ESPERA_BUCLE = 2800;        // ms con la web a la vista antes de repetir (solo demo)
  let repetirEnBucle = true;        // solo para la demostración

  const $ = (s, r = document) => r.querySelector(s);
  const $$ = (s, r = document) => [...r.querySelectorAll(s)];
  const html = document.documentElement;
  const pre = $("#pre"), sitio = $("#sitio"), estado = $("#estado");
  const reducido = matchMedia("(prefers-reduced-motion: reduce)");
  const tiras = $$(".pre__cortina span", pre);
  const atmos = $(".pre__atmos", pre);
  const uiSup = $$(".pre__arriba, .tareas-caja, .pre__horizonte", pre);
  const contador = $(".contador", pre);
  const preLogo = $(".pre__logo", pre);
  const punto = $(".logo__punto", pre);
  const pluma = $(".logo__pluma", pre);
  const navLogo = $(".nav__logo");
  const tareas = $$(".tareas li", pre);
  const tituloTareas = $(".tareas__titulo", pre);
  pre.style.animation = "none";

  // ---------- Carga real ----------
  const PESOS = { estructura: 15, fuentes: 30, paisaje: 40, detalles: 15 };
  const UMBRAL = { estructura: 15, fuentes: 45, paisaje: 85, detalles: 100 };
  const hecho = { estructura: 0, fuentes: 0, paisaje: 0, detalles: 0 };
  const progresoReal = () => Object.keys(PESOS).reduce((s, k) => s + PESOS[k] * hecho[k], 0);

  const CARAS = ['400 1em "Instrument Serif"', 'italic 400 1em "Instrument Serif"', '400 1em "Hanken Grotesk"', '600 1em "Hanken Grotesk"'];
  const cargado = (el) => new Promise((r) => { el.addEventListener("load", r, { once: true }); el.addEventListener("error", r, { once: true }); });
  // Estructura: el script de GSAP (async). Resuelve también si falla: la página no se queda bloqueada.
  const gsapListo = window.gsap ? Promise.resolve() : cargado($("#gsap-js"));
  gsapListo.then(() => { hecho.estructura = 1; });
  // Tipografías: primero la hoja de Google Fonts, luego cada cara.
  const hoja = $("#hoja-fuentes");
  let fuentesListas = 0;
  (hoja.sheet ? Promise.resolve() : cargado(hoja)).then(() => {
    CARAS.forEach((c) => {
      const p = document.fonts ? document.fonts.load(c) : Promise.resolve();
      p.catch(() => {}).then(() => { hecho.fuentes = ++fuentesListas / CARAS.length; });
    });
  });
  if (document.readyState === "complete") hecho.detalles = 1;
  else addEventListener("load", () => { hecho.detalles = 1; }, { once: true });
  pintarPaisaje($(".marco canvas"), (f) => { hecho.paisaje = f; });

  // ---------- Contador de rodillos ----------
  const digitos = $$(".digito", pre);
  digitos.forEach((d) => { d.innerHTML = '<span class="tira">' + [...Array(11).keys()].map((n) => "<span>" + (n % 10) + "</span>").join("") + "</span>"; });
  let ultimo = -1;
  function ponerNumero(n) {
    if (n === ultimo) return;
    ultimo = n;
    // en 100 las decenas y unidades avanzan al «0» extra (posición 10) en vez de rebobinar
    const ds = n === 100 ? [1, 10, 10] : [Math.floor(n / 100), Math.floor(n / 10) % 10, n % 10];
    contador.classList.toggle("final", n === 100);
    digitos.forEach((d, i) => {
      d.style.setProperty("--d", ds[i]);
      d.classList.toggle("apagado", (i === 0 && n < 100) || (i === 1 && n < 10));
    });
  }

  // ---------- Logotipo que se escribe ----------
  const trazos = $$(".logo__trazo", pre).map((el) => {
    const len = el.getTotalLength();
    el.style.strokeDasharray = len + " " + (len + 2);
    return { el, len };
  });
  const TOTAL = trazos.reduce((s, t) => s + t.len, 0);
  function dibujar(p) {
    let resto = TOTAL * p, punta = null;
    for (const t of trazos) {
      const d = Math.max(0, Math.min(t.len, resto));
      t.el.style.strokeDashoffset = t.len - d;
      t.el.style.opacity = d > 0.5 ? 1 : 0;
      if (!punta && d > 0 && d < t.len) punta = t.el.getPointAtLength(d);
      resto -= t.len;
    }
    if (punta) { pluma.setAttribute("cx", punta.x); pluma.setAttribute("cy", punta.y); pluma.style.opacity = 1; }
    else if (p <= 0 || p >= 1) pluma.style.opacity = 0;
  }

  function marcarTareas(v, nav) {
    let activa = false;
    tareas.forEach((li) => {
      const k = li.dataset.t;
      const lista = (nav || hecho[k] === 1) && v >= UMBRAL[k];
      li.classList.toggle("hecha", lista);
      li.classList.toggle("activa", !lista && !activa);
      if (!lista) activa = true;
    });
  }

  function pintar(v, nav) {
    ponerNumero(Math.floor(v));
    pre.style.setProperty("--p", (v / 100).toFixed(4));
    dibujar(v / 100);
    marcarTareas(v, nav);
  }

  // ---------- Bucle de carga ----------
  let saltar = null;
  function cargar(duracion, origen, nav) {
    return new Promise((resolve) => {
      let mostrado = 0, rapido = false, previo = performance.now();
      saltar = () => { rapido = true; };
      const tick = (ahora) => {
        const dt = Math.min(0.1, Math.max(0, (ahora - previo) / 1000));
        previo = ahora;
        const t = Math.min(1, (ahora - origen) / duracion);
        const tope = 100 * (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
        const real = nav || ahora - origen > DURACION_MAXIMA ? 100 : progresoReal();
        const objetivo = rapido ? 100 : Math.min(real, tope);
        mostrado += (objetivo - mostrado) * (1 - Math.exp(-dt * (rapido ? 14 : 7)));
        if (objetivo >= 100 && mostrado > 99.5) mostrado = 100;
        pintar(mostrado, nav);
        if (mostrado >= 100) { saltar = null; resolve(rapido); }
        else requestAnimationFrame(tick);
      };
      requestAnimationFrame(tick);
    });
  }

  // ---------- Salida: logo al menú, cortina hacia abajo, entrada de la portada ----------
  function volar() {
    const a = preLogo.getBoundingClientRect(), b = navLogo.getBoundingClientRect();
    return gsap.timeline()
      .to(preLogo, { x: b.left - a.left, y: b.top - a.top, scale: b.width / a.width, transformOrigin: "0 0", duration: 1.25, ease: "expo.inOut" }, 0)
      .to(preLogo, { color: getComputedStyle(navLogo).color, duration: 0.5, ease: "power1.inOut" }, 0.45)
      .set(navLogo, { opacity: 1 })
      .set(preLogo, { opacity: 0 });
  }
  function entrada() {
    return gsap.timeline()
      .fromTo(".hero__linea > span", { yPercent: 112 }, { yPercent: 0, duration: 1.25, ease: "expo.out", stagger: 0.1 }, 0)
      .fromTo(".marco", { clipPath: "inset(10% 12% 10% 12%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 1.5, ease: "expo.out" }, 0)
      .fromTo(".marco canvas", { scale: 1.22 }, { scale: 1, duration: 2, ease: "expo.out" }, 0)
      .fromTo(".aparece", { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.9, ease: "power3.out", stagger: 0.05 }, 0.2);
  }
  function salir(rapido) {
    return new Promise((resolve) => {
      const conFoco = pre.contains(document.activeElement);
      const tl = gsap.timeline({ onComplete: () => { terminar(conFoco); resolve(); } });
      tl.fromTo(punto, { opacity: 1, scale: 0, transformOrigin: "50% 50%" }, { scale: 1, duration: 0.55, ease: "back.out(2.6)" }, 0)
        .to(pluma, { opacity: 0, duration: 0.2 }, 0)
        .addLabel("sale", PAUSA_EN_100 / 1000)
        .to(contador, { yPercent: -104, duration: 0.75, ease: "power3.in" }, "sale")
        .to(uiSup, { autoAlpha: 0, duration: 0.45, ease: "power2.in" }, "sale")
        .to(atmos, { opacity: 0, duration: 0.7, ease: "power2.inOut" }, "sale+=0.25")
        .add(() => pre.classList.add("pre--abre"), "sale+=0.35")
        .to(tiras, { yPercent: 101, duration: 1.15, ease: "expo.inOut", stagger: { each: 0.06, from: "start" } }, "sale+=0.35")
        .add(volar(), "sale+=0.3")
        .add(() => { sitio.inert = false; html.classList.remove("precarga"); estado.textContent = NOMBRE + ": página cargada."; }, "sale+=0.9")
        .add(entrada(), "sale+=1");
      if (rapido) tl.timeScale(1.5);
    });
  }

  let ocupado = true, temporizador = 0, pendiente = false, enPantalla = true;
  function terminar(conFoco) {
    pre.hidden = true;
    pre.classList.remove("pre--abre");
    gsap.set(tiras, { yPercent: -101 });
    ocupado = false;
    if (conFoco) $("#titulo").focus();
    programarBucle();
  }

  function reiniciarUI() {
    pre.classList.add("sin-trans");
    ultimo = -1;
    pintar(0, true);
    void pre.offsetWidth;
    pre.classList.remove("sin-trans");
    gsap.set(contador, { yPercent: 0 });
    gsap.set(punto, { opacity: 0 });
    gsap.set(preLogo, { clearProps: "transform,color,opacity" });
  }

  // ---------- Cierre (cambio de página o repetición) ----------
  function cerrar(destino) {
    if (ocupado) return;
    clearTimeout(temporizador);
    if (reducido.matches || !window.gsap) return destello();
    ocupado = true;
    sitio.inert = true;
    html.classList.add("precarga");
    estado.textContent = "Cargando " + destino + "…";
    tituloTareas.textContent = destino === NOMBRE ? "Preparando la casa" : "Camino a " + destino;
    gsap.set(uiSup, { autoAlpha: 0 });
    gsap.set(atmos, { opacity: 0 });
    reiniciarUI();
    gsap.set(tiras, { yPercent: -101 });
    pre.hidden = false;
    // Con la cortina cerrada, la «página nueva» empieza arriba (como en una navegación real).
    gsap.timeline({ onComplete: () => { scrollTo(0, 0); cargar(DURACION_NAVEGACION, performance.now(), true).then(salir); } })
      .to(tiras, { yPercent: 0, duration: 0.95, ease: "expo.inOut", stagger: { each: 0.06, from: "start" } })
      .set(navLogo, { opacity: 0 })
      .to(atmos, { opacity: 1, duration: 0.3 }, "-=0.15")
      .to(uiSup, { autoAlpha: 1, duration: 0.45, ease: "power2.out" }, "-=0.2");
  }

  // Movimiento reducido: sin cortina ni rodillos, un fundido corto con el estado final.
  function destello() {
    ocupado = true;
    pre.classList.add("sin-trans", "estatico");
    ultimo = -1;
    pintar(100, true);
    punto.style.opacity = 1;
    punto.style.transform = "none";
    tiras.forEach((t) => { t.style.transform = "none"; });
    pre.hidden = false;
    requestAnimationFrame(() => { pre.style.opacity = 1; });
    setTimeout(() => { pre.style.opacity = 0; }, 900);
    setTimeout(() => { pre.hidden = true; pre.classList.remove("estatico", "sin-trans"); pre.style.opacity = ""; ocupado = false; }, 1250);
  }

  function sinPrecarga() {
    pre.hidden = true;
    pre.style.opacity = "";
    sitio.inert = false;
    html.classList.remove("precarga");
    navLogo.style.opacity = 1;
    ocupado = false;
  }

  // ---------- Repetición automática (solo demo) ----------
  function programarBucle() {
    clearTimeout(temporizador);
    if (!repetirEnBucle || reducido.matches || !window.gsap) return;
    temporizador = setTimeout(() => {
      if (document.hidden || !enPantalla) { pendiente = true; return; }
      cerrar(NOMBRE);
    }, ESPERA_BUCLE);
  }
  const reanudar = () => { if (pendiente && !document.hidden && enPantalla) { pendiente = false; programarBucle(); } };
  document.addEventListener("visibilitychange", reanudar);
  new IntersectionObserver(([e]) => { enPantalla = e.isIntersecting; reanudar(); }).observe(sitio);

  // ---------- Controles ----------
  $("#saltar").addEventListener("click", () => saltar && saltar());
  addEventListener("keydown", (e) => { if (e.key === "Escape" && saltar) saltar(); });
  $("#repetir").addEventListener("click", () => cerrar(NOMBRE));
  const botonBucle = $("#bucle");
  botonBucle.addEventListener("click", () => {
    repetirEnBucle = !repetirEnBucle;
    botonBucle.setAttribute("aria-pressed", repetirEnBucle);
    if (repetirEnBucle && !ocupado) programarBucle(); else clearTimeout(temporizador);
  });
  $$("[data-pagina]").forEach((a) => a.addEventListener("click", (e) => {
    e.preventDefault();
    $$(".nav [aria-current]").forEach((x) => x.removeAttribute("aria-current"));
    $$('.nav a[data-pagina="' + a.dataset.pagina + '"]').forEach((x) => x.setAttribute("aria-current", "page"));
    cerrar(a.dataset.pagina);
  }));

  // ---------- Arranque ----------
  const sinBucle = () => { repetirEnBucle = false; botonBucle.setAttribute("aria-pressed", "false"); };
  if (reducido.matches) { sinBucle(); sinPrecarga(); return; }
  sitio.inert = true;
  estado.textContent = "Cargando " + NOMBRE + "…";
  pintar(0, false);
  // Origen 0 = inicio de la navegación: la duración mínima cuenta desde que la página empezó a cargar.
  cargar(DURACION_MINIMA, 0, false).then((rapido) => gsapListo.then(() => {
    if (window.gsap) return salir(rapido);
    // Sin GSAP (CDN caído): fundido corto y a la web.
    sinBucle();
    pre.style.transition = "opacity .4s";
    pre.style.opacity = 0;
    setTimeout(() => { sinPrecarga(); estado.textContent = NOMBRE + ": página cargada."; }, 400);
  }));

  // ---------- Paisaje procedural (tarea real de carga) ----------
  function pintarPaisaje(cv, progreso) {
    const r = cv.getBoundingClientRect();
    const dpr = Math.min(devicePixelRatio || 1, 2);
    const esc = Math.min(1, 1500 / (Math.max(r.width, r.height, 1) * dpr));
    const W = (cv.width = Math.max(320, Math.round(r.width * dpr * esc)));
    const H = (cv.height = Math.max(400, Math.round(r.height * dpr * esc)));
    const c = cv.getContext("2d");
    const onda = (x, s) => Math.sin(x * 1.7 + s) * 0.5 + Math.sin(x * 3.9 + s * 2.3) * 0.25 + Math.sin(x * 9.1 + s * 0.7) * 0.12 + Math.sin(x * 23 + s * 5) * 0.05;
    const cresta = (col, base, amp, s) => {
      c.fillStyle = col;
      c.beginPath();
      c.moveTo(0, H);
      for (let i = 0; i <= 140; i++) { const x = (W * i) / 140; c.lineTo(x, H * (base - amp * onda((x / W) * 3, s))); }
      c.lineTo(W, H);
      c.fill();
    };
    const pasos = [
      () => { const g = c.createLinearGradient(0, 0, 0, H * 0.64); g.addColorStop(0, "#cdb6ad"); g.addColorStop(0.5, "#e9ae86"); g.addColorStop(1, "#f7d3a1"); c.fillStyle = g; c.fillRect(0, 0, W, H); },
      () => { const x = W * 0.63, y = H * 0.5, s = Math.min(W, H) * 0.06; const g = c.createRadialGradient(x, y, 0, x, y, s * 7); g.addColorStop(0, "rgba(255,240,210,.95)"); g.addColorStop(0.2, "rgba(255,212,160,.5)"); g.addColorStop(1, "rgba(255,200,150,0)"); c.fillStyle = g; c.fillRect(0, 0, W, H); c.fillStyle = "#fff4de"; c.beginPath(); c.arc(x, y, s, 0, 7); c.fill(); },
      () => cresta("#d9a58c", 0.52, 0.03, 1.3),
      () => cresta("#c48670", 0.565, 0.045, 4.1),
      () => cresta("#a8664f", 0.62, 0.05, 8.6),
      () => cresta("#83493a", 0.68, 0.045, 2.2),
      () => { // bruma en el fondo del tajo
        const g = c.createLinearGradient(0, H * 0.68, 0, H); g.addColorStop(0, "#f0bf94"); g.addColorStop(1, "#c7896a");
        c.fillStyle = g; c.fillRect(W * 0.3, H * 0.68, W * 0.4, H * 0.32);
      },
      () => { // paredes del tajo
        c.fillStyle = "#5a2f26";
        c.beginPath(); c.moveTo(0, H * 0.698); c.lineTo(W * 0.22, H * 0.692); c.lineTo(W * 0.33, H * 0.7); c.lineTo(W * 0.355, H * 0.725); c.lineTo(W * 0.372, H * 0.79); c.lineTo(W * 0.362, H * 0.85); c.lineTo(W * 0.392, H * 0.93); c.lineTo(W * 0.4, H); c.lineTo(0, H); c.fill();
        c.beginPath(); c.moveTo(W, H * 0.676); c.lineTo(W * 0.7, H * 0.682); c.lineTo(W * 0.645, H * 0.696); c.lineTo(W * 0.625, H * 0.75); c.lineTo(W * 0.635, H * 0.82); c.lineTo(W * 0.605, H * 0.9); c.lineTo(W * 0.595, H); c.lineTo(W, H); c.fill();
      },
      () => { // el puente, con la luz entrando por los arcos
        c.fillStyle = "#3b2019";
        const y0 = H * 0.69, cx = W * 0.495, r = W * 0.068, arco = (x, rr, ys, yb) => { c.moveTo(x - rr, yb); c.lineTo(x - rr, ys); c.arc(x, ys, rr, Math.PI, 0); c.lineTo(x + rr, yb); c.closePath(); };
        c.beginPath();
        c.rect(W * 0.325, y0 - H * 0.012, W * 0.34, H * 0.014);
        c.rect(W * 0.37, y0, W * 0.25, H - y0);
        arco(cx, r, y0 + H * 0.12, H);
        arco(W * 0.405, W * 0.016, y0 + H * 0.045, y0 + H * 0.1);
        arco(W * 0.585, W * 0.016, y0 + H * 0.045, y0 + H * 0.1);
        arco(cx, W * 0.02, y0 + H * 0.04, y0 + H * 0.075);
        c.fill("evenodd");
      },
      () => { // casas encaladas al borde
        const casa = (x, y, w, h, k) => { c.fillStyle = k % 3 ? "#f3e7d4" : "#e6d3bb"; c.fillRect(x, y - h, w, h); c.fillStyle = "#7a4b3b"; c.fillRect(x + w * 0.32, y - h * 0.62, w * 0.16, h * 0.28); };
        for (let i = 0; i < 8; i++) casa(W * 0.665 + i * W * 0.042, H * (0.679 - (i % 3) * 0.002), W * (0.034 + (i % 4) * 0.006), H * (0.02 + ((i * 7) % 4) * 0.005), i);
        for (let i = 0; i < 5; i++) casa(W * 0.04 + i * W * 0.052, H * 0.694, W * (0.03 + (i % 3) * 0.007), H * (0.016 + (i % 2) * 0.006), i + 1);
      },
      () => { // haz de luz sobre el barranco
        const g = c.createLinearGradient(0, H * 0.3, 0, H * 0.9); g.addColorStop(0, "rgba(255,226,186,0)"); g.addColorStop(0.5, "rgba(255,226,186,.22)"); g.addColorStop(1, "rgba(255,226,186,0)"); c.fillStyle = g; c.fillRect(0, 0, W, H);
      },
      () => { // barandilla del balcón
        c.fillStyle = "#1f1512";
        const top = H * 0.87, bar = Math.max(2, W * 0.006), n = 16;
        c.fillRect(0, top, W, H * 0.012);
        c.fillRect(0, top + H * 0.034, W, H * 0.005);
        c.fillRect(0, H * 0.975, W, H * 0.025);
        for (let i = 0; i <= n; i++) c.fillRect((W * i) / n - bar / 2, top, bar, H - top);
      },
    ];
    const bandas = 6;
    for (let b = 0; b < bandas; b++) pasos.push(() => {
      const y = Math.floor((H * b) / bandas), h = Math.ceil(H / bandas);
      const img = c.getImageData(0, y, W, h), d = img.data;
      for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
      c.putImageData(img, 0, y);
    });
    let i = 0;
    const siguiente = () => { pasos[i++](); progreso(i / pasos.length); if (i < pasos.length) setTimeout(siguiente, 16); };
    siguiente();
  }
})();
```

Puntos delicados del código:
- `performance.now()` cuenta desde el inicio de la navegación: por eso la primera carga usa `origen = 0` y la duración mínima incluye lo que tardó el HTML.
- `strokeDasharray = len + " " + (len + 2)` y opacidad 0 para trazos sin empezar: evita el puntito que dejan los extremos redondos con longitud cero.
- El 100 lleva decenas y unidades a la posición 10 (el «0» extra) para que rueden hacia delante en vez de rebobinar del 9 al 0.
- El paisaje se pinta en 14 pasos separados con `setTimeout(…, 16)`: es trabajo real que alimenta la tarea «Paisaje».
- `pre.style.animation = "none"` anula el seguro CSS (`animation: seguro 0s 12s forwards`, que oculta el preloader a los 12 s si el JS fallara).
- Si el CDN de GSAP falla, el contador llega igualmente a 100 y el preloader se funde en 0,4 s.

## Interacción

- **Puntero**: los enlaces del menú tienen un subrayado que crece de 0 a 100 % (0,5 s, `cubic-bezier(.16,1,.3,1)`); las flechas de «Reservar» se desplazan 4 px; el botón oscuro pasa a color acento. Cualquier enlace con `data-pagina` lanza el cierre y marca con `aria-current="page"` el enlace del menú correspondiente.
- **Teclado**: el primer Tab cae en «Saltar intro»; Enter o Esc saltan. Si el foco estaba dentro del preloader al salir, pasa al `h1` (`tabindex="-1"`). Mientras carga, la web tiene `inert`.
- **Táctil**: no hay nada que dependa del cursor; todo es automático o por toque.
- **Demo**: «Repetir la carga» lanza el ciclo; «Repetición automática» es un `aria-pressed` que activa o para el bucle. El bucle no se dispara con la pestaña oculta o la pieza fuera de pantalla (IntersectionObserver): queda pendiente y se reanuda al volver.

## Adaptación a móvil y tablet

- **≤ 900 px**: la portada pasa a una columna (texto, foto 4:3 con 36 px arriba, datos); titular `clamp(3.3rem, 14.5vw, 7rem)`; se oculta el menú central (quedan logotipo y «Reservar»).
- **≤ 600 px**: logotipo del menú 66 px; datos en 2 columnas (el correo baja a una fila entera); en el preloader se ocultan las coordenadas y la lista de tareas, la cortina usa 4 franjas (`span:nth-child(n+5){display:none}`), el contador mide `36vw`, el logotipo `64vw` y sube más (`padding-bottom: 14vh`).
- Márgenes laterales `clamp(16px, 3.2vw, 48px)`. Sin scroll horizontal de 360 a 1920 px. Alturas con `100svh`.

## Accesibilidad y movimiento reducido

- `prefers-reduced-motion: reduce`: el CSS oculta el preloader desde el primer píxel y el JS arranca directamente con la web visible; el interruptor de repetición se pone a `aria-pressed="false"`. «Repetir la carga» y los enlaces solo hacen un fundido de 0,25 s con el estado final (100, logotipo completo) durante 0,9 s.
- El contador, el logotipo y la lista de tareas son decorativos (`aria-hidden`). El progreso se anuncia en `#estado` (`role="status"`): «Cargando Casa Alba…» y «Casa Alba: página cargada.» (en los cambios de página, «Cargando La casa…»).
- Foco visible: contorno de 2 px en color acento (crema dentro del preloader). Contraste AA: tinta suave `#5b544a` y acento sobre el fondo cal; textos secundarios del preloader al 66 %.
- Sin JavaScript no se ve el preloader (la clase `js` la pone un script en `<head>`).

## Rendimiento

- Sin WebGL. El canvas del paisaje se pinta una vez, con DPR máximo 2 y el lado largo limitado a 1 500 px; `object-fit: cover` lo adapta al redimensionar.
- El bucle de `requestAnimationFrame` del contador se detiene al llegar a 100. Las franjas llevan `will-change: transform`. `--p` es la única variable que cambia por fotograma en el preloader.
- Repetición automática pausada con la pestaña oculta o fuera de pantalla.
- Menos de 40 KB de HTML.

## Personalización

- **Marca y textos**: `Casa Alba`, `Arvale`, `+00 000 000 000` (en el `href` de `tel:` escríbelo sin espacios), `hola@casaalba.com`, `145`. El titular «Despertar / sobre el Cañón.», las coordenadas «47° 12′ N · 8° 25′ E» y los rótulos del paisaje son de Arvale: cámbialos si la casa está en otro sitio.
- **Colores**: `#12141b` (noche), `#efe8dc` (cal), `#1a1712` (tinta), `#a3431f` (terracota, para textos pequeños mantén contraste AA) y `#e8743f` (resplandor, pluma, marcas y línea de la cortina). Si cambias tinta o fondo, ajusta sus versiones `rgba(...)` listadas en «Tokens».
- **Ritmo**: `DURACION_MINIMA` (`4200`), `DURACION_NAVEGACION`, `PAUSA_EN_100` y la curva `power2.inOut`. No bajes de 2 500 ms la mínima o el logotipo no llega a leerse.
- **Logotipo**: sustituye los siete trazos por los de tu marca (trazos abiertos con `stroke`, no rellenos) en el mismo orden en que se escribirían, y usa el mismo `viewBox` y grosor en el del menú.
- **Tareas**: añade tareas reales (imágenes con `img.decode()`, datos con `fetch`) con su peso en `PESOS` y su umbral en `UMBRAL`.
- **En producción** borra la fila «Demostración», el bucle (`ESPERA_BUCLE`, `repetirEnBucle`) y, si no quieres transición entre páginas, el cierre.

## Comprobación final

1. Al abrir la página, el preloader azul noche se ve desde el primer instante, sin destello de la web.
2. El contador empieza en «000» con los dos ceros apagados y rueda como un odómetro hasta «100»; nunca retrocede.
3. El logotipo «alba.» se escribe trazo a trazo con la pluma naranja en la punta y termina justo en 100 con el punto rebotando.
4. La línea del horizonte y el resplandor inferior crecen con el mismo número que el contador.
5. Las tareas pasan de «En cola» a «Cargando» a «Listo» en orden, y ninguna dice «Listo» antes de que el contador alcance su umbral.
6. Con la red lenta (throttling), el contador espera a la carga real; con red rápida tarda como mínimo 4200 ms desde el inicio de la navegación.
7. En 100: las cifras suben tras su máscara, las 6 franjas caen de izquierda a derecha con su filo naranja y el logotipo vuela y encaja exactamente sobre el del menú, ya en color tinta.
8. El titular entra por líneas, la foto se abre desde el recorte y se asienta; el resto aparece con un fundido corto.
9. «Saltar intro» y Esc aceleran el final; el foco pasa al titular.
10. Los enlaces internos cierran la cortina desde arriba, el contador corre en unos 2 s y la web vuelve a abrirse arriba del todo.
11. Con `prefers-reduced-motion: reduce` no hay preloader: la web aparece directamente.
12. De 360 a 1920 px no hay scroll horizontal; en móvil se ven 4 franjas y el contador ocupa buena parte del ancho.
13. Cero errores en consola, también si se bloquea el CDN de GSAP (el preloader se funde y deja la web usable).

Prueba en frío

Lo que salió al pegarlo 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.

Lo que generó la IA con el prompt Completo de Preloader con contador y cortina
Prompt CompletoFiel a la demo9,8/10

Prácticamente idéntica a la demo: mismo contador de rodillos, logotipo que se escribe, cortina de seis franjas, vuelo del logo al menú y portada con el paisaje del Tajo. Las capturas solo difieren en el instante en que se tomaron, no en el diseño ni en el movimiento.

Lo que generó la IA con el prompt Lite de Preloader con contador y cortina
Prompt LiteFiel a la demo7,2/10

El movimiento es el mismo (carga real, odómetro, pluma, franjas que caen con los mismos tiempos y easings, logo que vuela al menú, bucle de demo) y la paleta, la tipografía y el titular coinciden. Cambia la piel: botones en píldora, datos en lista vertical, tareas en una caja con borde y un paisaje distinto, con casas con tejado y barandilla de bolas.

Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.

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:
    • «Pon mi logotipo en SVG para que se escriba trazo a trazo»
    • «Que la cortina se abra hacia los lados desde el centro»
    • «Haz la carga mínima más corta, de unos 2,5 segundos»
    • «Cambia la noche a verde botella y el resplandor a dorado»
  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

¿El contador refleja de verdad lo que tarda mi web?
Sí. Suma tareas reales con su peso (script, tipografías, dibujo de la portada y el evento load) y nunca muestra más de lo que ya ha cargado. Solo impone una duración mínima para que no salte a 100 de golpe; puedes añadir tus imágenes o datos como tareas nuevas.
¿Molesta a quien vuelve a la web o tiene prisa?
Se salta con el botón «Saltar intro» o con Esc, no aparece si el sistema pide menos movimiento y la duración mínima es una variable. Si quieres, muéstralo solo en la primera visita guardando una marca en sessionStorage.
¿Puedo usarlo con mi propio logotipo?
Sí, si es un SVG de trazos abiertos (stroke) en vez de rellenos. Sustituye los siete trazos por los tuyos en el orden en que se escribirían y usa el mismo viewBox en el logotipo del menú para que el vuelo encaje.
¿Qué pasa si falla la carga de GSAP?
El contador funciona sin GSAP, así que llega igualmente a 100; entonces el preloader se funde en 0,4 segundos y la web queda usable. Además hay un seguro CSS que lo oculta a los 12 segundos si el JavaScript fallara.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈14976 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2713 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.