
Titular gigante
GratisBloques de web · GSAP
La pieza
Un cursor propio hecho a mano, sin librerías. En reposo es un punto con un aro que lo sigue con un muelle y se estira al ir rápido. Al acercarse a un botón, enlace o chip, el elemento se desplaza hacia el puntero y el aro cambia de forma hasta envolverlo y lo pone en negativo con mix-blend-mode: difference. Sobre los titulares se convierte en una lupa de 150 px que invierte las letras, y sobre las fotos en un disco con texto («Ver proyecto», «Abrir serie») que se inclina al moverlo. El bucle se detiene cuando todo está quieto. En táctil desaparece: las fotos muestran su etiqueta fija y los botones dejan un eco al tocarlos. Encaja en portfolios, estudios creativos, agencias, arquitectura y marcas de producto cuidado. Funciona igual con fondo oscuro: el negativo se recalcula a partir del color del fondo.
Encaja bien en webs de
Antes de copiar
Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.
Gratis y entero
Qué trae el prompt, por apartados
Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.
# Recrea en un único archivo HTML: Cursor magnético
Un cursor propio de punto y aro que se imanta a botones y enlaces, se convierte en lupa invertida sobre los titulares y en una etiqueta «Ver proyecto» sobre las imágenes: la web parece responder a la mano antes de que hagas clic.
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 web de una sola página para **Estudio Salitre**, un estudio ficticio de dirección de arte y fotografía de Valmont, construida para lucir un **cursor magnético** hecho a mano (JS vanilla + CSS, sin librerías). El cursor tiene cuatro estados y todos se interpolan con muelles físicos, nunca con transiciones CSS:
1. **Reposo**: un punto de 6 px que va pegado al puntero y un aro de 38 px con borde de 1,5 px que lo sigue con un muelle (ligero retraso y un pequeño rebote). Cuando el aro va rápido se estira en la dirección del movimiento (hasta un 30 %).
2. **Imán** (botones, enlaces, chips y el sello redondo, marcados con `data-magnet`): al entrar en una zona que rodea al elemento (12–40 px más allá de su borde), el elemento se desplaza hacia el puntero (30 % de la distancia a su centro, o el valor de `data-magnet`), su contenido interior se desplaza un 50 % extra, y el aro abandona el puntero, se pega al elemento y **cambia de forma** hasta envolverlo con 6 px de holgura y su mismo radio (píldora, círculo o píldora de texto). A la vez se rellena por completo. Como el aro usa `mix-blend-mode: difference` con el color del fondo, el botón se ve **en negativo**: un botón tinta pasa a claro con texto oscuro y un halo negro de 6 px alrededor. El punto desaparece (escala 0). Al salir, el elemento vuelve a su sitio con un rebote elástico.
3. **Lupa** (las tres líneas del titular, marcadas con `data-cursor="lens"`): el aro crece a un círculo relleno de 150 px que invierte lo que tiene debajo: el fondo se vuelve negro, la tinta clara y la palabra en color acento, su complementario (turquesa).
4. **Etiqueta** (las cuatro fotos de proyectos, marcadas con `data-cursor="label"` y `data-cursor-text`): el aro se encoge y se oculta; aparece un disco de tinta de 112 px, **sin** mezcla, con el texto del proyecto («Abrir serie», «Ver proyecto», «Ver campaña», «Ver piezas») y una flecha diagonal. Entra escalando desde 0 con muelle y se inclina hasta ±10° según la velocidad horizontal.
Además: al pulsar, el aro se encoge a 0,84; al salir del documento el cursor se desvanece; el bucle `requestAnimationFrame` **se detiene solo** cuando todo está en reposo. En pantallas táctiles el cursor no existe: las fotos muestran su etiqueta como chip fijo y, al tocar un botón imantado, se dibuja un «eco» del aro que se expande y se apaga. Un interruptor «Cursor propio» permite desactivarlo. Paleta cálida de yeso, tinta casi negra y un acento almagra; tipografía editorial Instrument Serif con Geist.
## Librerías y esqueleto
- Sin librerías JS. Todo es HTML, CSS y un único `<script>` clásico al final del `<body>` (una IIFE).
- Fuentes de Google (tienen ñ, tildes, ¿ y ¡): **Instrument Serif** (regular e itálica) para display y **Geist** 400/500/600 para texto.
- Sin imágenes: las «fotos» son composiciones CSS en duotono con grano SVG `feTurbulence` en línea.
Cabecera literal del documento:
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Salitre — Cursor magnético</title>
<meta name="description" content="Estudio Salitre: dirección de arte y fotografía en Valmont. Demo de cursor magnético.">
<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=Geist:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<style>
/* aquí va el :root de «Tokens de diseño» y después todo el CSS de «Código clave» */
</style>
</head>
<body>
<!-- aquí va el HTML de «Estructura y contenido» -->
<script>
/* aquí va el JS de «Código clave» */
</script>
</body>
</html>
```
## Tokens de diseño
```css
:root{
--fondo:#ece6dc;
--tinta:#16140f;
--acento:#a63f1e;
--tinta-suave:#4a453c;
--linea:color-mix(in srgb, var(--tinta) 16%, transparent);
--cursor:var(--fondo);
--f-display:"Instrument Serif", Georgia, "Times New Roman", serif;
--f-texto:"Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-out:cubic-bezier(.16,1,.3,1);
--ease-io:cubic-bezier(.65,0,.35,1);
--gutter:clamp(16px, 3.9vw, 56px);
--aro:38px;
--grano:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
```
Notas:
- `--cursor` es el color del fondo: con `mix-blend-mode: difference`, el aro sobre el fondo da negro puro y sobre la tinta da un tono casi igual al fondo. Así el negativo siempre casa con la paleta.
- El resto de tonos derivados de la tinta se escriben con `color-mix(in srgb, var(--tinta) N%, transparent)` (líneas 16 %, chips 40 %, interruptor apagado 35 %, rótulos de foto 62 %).
- Contraste: tinta sobre fondo ≈ 16:1; `--tinta-suave` sobre fondo ≈ 7,7:1; el acento sobre fondo ≈ 5:1 (se usa solo en titulares grandes y en el punto de la pista).
## Estructura y contenido
Árbol de la página (todos los textos son literales):
- `header.portada#inicio` (alto mínimo `100svh`, rejilla de 3 filas: cabecera / cuerpo / pie)
- `.cabecera`: marca «Salitre» en Instrument Serif itálica 30 px + «ESTUDIO» (12 px, versalitas espaciadas). Nav: «Trabajo», «Estudio», «Diario», «Contacto» y botón píldora tinta «Hablemos →». Todos imantados.
- `.portada-cuerpo`: antetítulo «DIRECCIÓN DE ARTE Y FOTOGRAFÍA · Valmont» con un guion de 28 px delante; `h1` en tres líneas escalonadas: «Imágenes» (sin sangría) / «que *atraen*» (sangría 15vw, «atraen» en itálica color acento) / «la mirada.» (sangría 30vw). Cada línea es lupa.
- `.portada-pie`: sello redondo de 168 px (tinta, texto circular «DIRECCIÓN DE ARTE · FOTOGRAFÍA · CAMPAÑAS ·» girando, flecha ↓ en el centro), colocado en absoluto con su centro al 77,7 % del ancho y al 64 % del alto de la portada; bloque de entrada con párrafo y dos botones («Ver trabajos →» tinta, «Pedir presupuesto» con borde); columna derecha con coordenadas «40°00′ N · 10°00′ E», pista con punto que late («Mueve el ratón: los botones te buscan» / en táctil «En táctil, toca un botón y verás su eco») e interruptor «Cursor propio».
- `main`
- `section.seccion#trabajo`: cabecera con antetítulo «01 — TRABAJO SELECCIONADO», `h2` «Lo último que / hemos mirado» y enlace subrayado «Ver archivo completo ↗» a la derecha, línea inferior. Rejilla de 12 columnas con 4 obras:
1. «Cal y sombra» — Casa Alhelí · Arquitectura · 2025 — foto vertical 4:5 (5 columnas), rótulo «Foto: patio blanco junto al mar», etiqueta «Abrir serie».
2. «Mar de fondo» — Conservas La Lonja · Campaña · 2026 — foto 4:3 (7 columnas, bajada 8vw), rótulo «Foto: horizonte en La Malagueta», etiqueta «Ver proyecto».
3. «Hora dorada» — Bodega Sierra Alta · Etiqueta y campaña · 2024 — foto 4:3 (7 columnas), rótulo «Foto: viñedo en el valle de Alder», etiqueta «Ver campaña».
4. «Barro lento» — Cerámica Tres Lunas · Producto · 2025 — foto vertical 4:5 (5 columnas, bajada 8vw), rótulo «Foto: botijo sobre mármol», etiqueta «Ver piezas».
- `section.contacto#contacto`: antetítulo «02 — CONTACTO»; `h2` «¿Hacemos algo / que *atraiga*?» con párrafo a la derecha; «CUÉNTANOS QUÉ NECESITAS» y cinco chips conmutables (`aria-pressed`): «Campaña» (activo), «Fotografía de producto», «Arquitectura e interiores», «Vídeo corto», «Dirección de arte»; correo gigante «hola@salitre.com» imantado con fuerza 0,12; tres columnas: Teléfono («+00 000 000 000»), Estudio («Barrio del Puerto, Valmont» / «Lunes a viernes, de 9:00 a 18:00»), Redes (Instagram, Vimeo, LinkedIn en pestaña nueva); pie «© 2026 Estudio Salitre · Valmont» y «Sesiones desde 1.200 € IVA incl.».
- Fuera de todo, al final del `body`: los cuatro elementos del cursor (`.c-aro` con `.c-relleno` dentro, `.c-punto`, `.c-etiqueta`, `.c-eco`), todos `aria-hidden="true"`. Tienen que ser hijos directos de `body` para que `mix-blend-mode` mezcle con la página.
HTML literal del `body` (sin el `<script>`). En el `href` del teléfono escribe el número sin espacios:
```html
<header class="portada" id="inicio">
<div class="cabecera">
<a class="marca" href="#inicio" data-magnet="0.25" aria-label="Salitre, estudio. Inicio"><span class="mag-in"><b>Salitre</b><small>estudio</small></span></a>
<nav class="nav" aria-label="Principal">
<ul>
<li><a href="#trabajo" data-magnet><span class="mag-in">Trabajo</span></a></li>
<li><a href="#contacto" data-magnet><span class="mag-in">Estudio</span></a></li>
<li><a href="#trabajo" data-magnet><span class="mag-in">Diario</span></a></li>
<li><a href="#contacto" data-magnet><span class="mag-in">Contacto</span></a></li>
</ul>
<a class="btn btn-lleno btn-peq" href="#contacto" data-magnet><span class="mag-in">Hablemos <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4"/></svg></span></a>
</nav>
</div>
<div class="portada-cuerpo">
<p class="antetitulo">Dirección de arte y fotografía · Valmont</p>
<h1 class="titular">
<span class="l"><span data-cursor="lens">Imágenes</span></span>
<span class="l l2"><span data-cursor="lens">que <em>atraen</em></span></span>
<span class="l l3"><span data-cursor="lens">la mirada.</span></span>
</h1>
</div>
<div class="portada-pie">
<a class="sello" href="#trabajo" data-magnet="0.4" aria-label="Bajar a los trabajos">
<svg class="sello-texto" viewBox="0 0 200 200" aria-hidden="true"><defs><path id="circulo" d="M100,100 m-76,0 a76,76 0 1,1 152,0 a76,76 0 1,1 -152,0"/></defs><text><textPath href="#circulo" textLength="474" lengthAdjust="spacing">DIRECCIÓN DE ARTE · FOTOGRAFÍA · CAMPAÑAS ·</textPath></text></svg>
<span class="mag-in sello-flecha"><svg viewBox="0 0 34 34" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M17 7v20M9 19l8 8 8-8"/></svg></span>
</a>
<div class="entrada">
<p>Campañas, producto y arquitectura para marcas con carácter que prefieren mirar despacio. Trabajamos desde Valmont para todo el mundo.</p>
<div class="acciones">
<a class="btn btn-lleno" href="#trabajo" data-magnet><span class="mag-in">Ver trabajos <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4"/></svg></span></a>
<a class="btn btn-linea" href="#contacto" data-magnet><span class="mag-in">Pedir presupuesto</span></a>
</div>
</div>
<div class="meta">
<span class="coordenadas">40°00′ N · 10°00′ E</span>
<span class="pista"><i aria-hidden="true"></i><span class="raton">Mueve el ratón: los botones te buscan</span><span class="tactil">En táctil, toca un botón y verás su eco</span></span>
<button class="interruptor" type="button" aria-pressed="true" id="interruptor">Cursor propio <span class="pildora" aria-hidden="true"></span></button>
</div>
</div>
</header>
<main>
<section class="seccion" id="trabajo" aria-labelledby="t-trabajo">
<div class="seccion-cab">
<div>
<p class="antetitulo">01 — Trabajo seleccionado</p>
<h2 id="t-trabajo">Lo último que<br>hemos mirado</h2>
</div>
<a class="enlace-flecha" href="#" data-magnet><span class="mag-in">Ver archivo completo <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 10l6-6M5 4h5v5"/></svg></span></a>
</div>
<div class="obras">
<a class="obra f-cal" href="#" data-cursor="label" data-cursor-text="Abrir serie">
<div class="marco" aria-hidden="true"><div class="img"><div class="arco"></div></div><span class="rotulo">Foto: patio blanco junto al mar</span><span class="chip-tactil">Abrir serie <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 10l6-6M5 4h5v5"/></svg></span></div>
<div class="pie-obra"><h3>Cal y sombra</h3><p>Casa Alhelí<br>Arquitectura · <span class="n">2025</span></p></div>
</a>
<a class="obra f-mar" href="#" data-cursor="label" data-cursor-text="Ver proyecto">
<div class="marco" aria-hidden="true"><div class="img"></div><span class="rotulo">Foto: horizonte en La Malagueta</span><span class="chip-tactil">Ver proyecto <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 10l6-6M5 4h5v5"/></svg></span></div>
<div class="pie-obra"><h3>Mar de fondo</h3><p>Conservas La Lonja<br>Campaña · <span class="n">2026</span></p></div>
</a>
<a class="obra f-hora" href="#" data-cursor="label" data-cursor-text="Ver campaña">
<div class="marco" aria-hidden="true"><div class="img"></div><span class="rotulo">Foto: viñedo en el valle de Alder</span><span class="chip-tactil">Ver campaña <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 10l6-6M5 4h5v5"/></svg></span></div>
<div class="pie-obra"><h3>Hora dorada</h3><p>Bodega Sierra Alta<br>Etiqueta y campaña · <span class="n">2024</span></p></div>
</a>
<a class="obra f-barro" href="#" data-cursor="label" data-cursor-text="Ver piezas">
<div class="marco" aria-hidden="true"><div class="img"><div class="vasija"></div></div><span class="rotulo">Foto: botijo sobre mármol</span><span class="chip-tactil">Ver piezas <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 10l6-6M5 4h5v5"/></svg></span></div>
<div class="pie-obra"><h3>Barro lento</h3><p>Cerámica Tres Lunas<br>Producto · <span class="n">2025</span></p></div>
</a>
</div>
</section>
<section class="contacto" id="contacto" aria-labelledby="t-contacto">
<p class="antetitulo">02 — Contacto</p>
<div class="contacto-cab">
<h2 id="t-contacto">¿Hacemos algo<br>que <em>atraiga</em>?</h2>
<p>Respondemos en un día laborable. Si ya tienes fecha, dínosla: reservamos las sesiones con tres semanas de margen.</p>
</div>
<p class="temas-tit" id="t-temas">Cuéntanos qué necesitas</p>
<div class="temas" role="group" aria-labelledby="t-temas">
<button class="tema" type="button" aria-pressed="true" data-magnet><span class="mag-in">Campaña</span></button>
<button class="tema" type="button" aria-pressed="false" data-magnet><span class="mag-in">Fotografía de producto</span></button>
<button class="tema" type="button" aria-pressed="false" data-magnet><span class="mag-in">Arquitectura e interiores</span></button>
<button class="tema" type="button" aria-pressed="false" data-magnet><span class="mag-in">Vídeo corto</span></button>
<button class="tema" type="button" aria-pressed="false" data-magnet><span class="mag-in">Dirección de arte</span></button>
</div>
<a class="correo" href="mailto:hola@salitre.com" data-magnet="0.12"><span class="mag-in">hola@salitre.com</span></a>
<div class="datos">
<div><h3>Teléfono</h3><p><a href="tel:+00 000 000 000" data-magnet><span class="mag-in">+00 000 000 000</span></a></p></div>
<div><h3>Estudio</h3><p>Barrio del Puerto, Valmont<br>Lunes a viernes, de 9:00 a 18:00</p></div>
<div><h3>Redes</h3><p class="redes">
<a href="https://www.instagram.com/" target="_blank" rel="noopener" data-magnet><span class="mag-in">Instagram</span></a>
<a href="https://vimeo.com/" target="_blank" rel="noopener" data-magnet><span class="mag-in">Vimeo</span></a>
<a href="https://www.linkedin.com/" target="_blank" rel="noopener" data-magnet><span class="mag-in">LinkedIn</span></a>
</p></div>
</div>
<footer class="final"><span>© 2026 Estudio Salitre · Valmont</span><span>Sesiones desde 1.200 € IVA incl.</span></footer>
</section>
</main>
<div class="c-aro" aria-hidden="true"><div class="c-relleno"></div></div>
<div class="c-punto" aria-hidden="true"></div>
<div class="c-etiqueta" aria-hidden="true"><div><span>Ver proyecto</span><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 10l6-6M5 4h5v5"/></svg></div></div>
<div class="c-eco" aria-hidden="true"></div>
```
## Movimiento
| Elemento | Propiedad | De → a | Duración / física | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Líneas del `h1` (span interior) | `transform` | `translateY(105%)` → `none` (la línea tiene `overflow:hidden`) | 1,15 s | `cubic-bezier(.16,1,.3,1)` | 0 / 0,09 / 0,18 s | carga |
| `.cabecera` | `opacity` | 0 → 1 | 0,9 s | `cubic-bezier(.16,1,.3,1)` | 0,1 s | carga |
| `.antetitulo`, `.portada-pie`, `.sello` | `opacity` | 0 → 1 | 0,9 s | `cubic-bezier(.16,1,.3,1)` | 0,35 s | carga |
| Texto circular del sello | `rotate` | 0 → 360° | 24 s, infinito | `linear` | — | siempre (se pausa fuera de pantalla) |
| Punto de la pista | `scale` + `opacity` | 1 → 0,55 → 1 / 1 → 0,5 → 1 | 2,2 s, infinito | `cubic-bezier(.65,0,.35,1)` | — | siempre |
| Foto de obra (`.img`) | `scale` | 1 → 1,045 | 1,1 s | `cubic-bezier(.16,1,.3,1)` | — | `:hover` |
| Aro: posición x/y | muelle | puntero o centro del imán | k = 340, c = 30 | física | — | puntero |
| Aro: ancho, alto, radio, relleno, escala del punto, presión | muelle | según estado | k = 260, c = 25 | física | — | cambio de estado |
| Elemento imantado | `translate3d` | 0 → (puntero − centro) × fuerza | k = 170, c = 13 (rebota) | física | — | entrar/salir de la zona |
| Contenido `.mag-in` | `translate3d` | desplazamiento del imán × 0,5 (extra) | igual que el imán | — | — | imán |
| Etiqueta | `scale` | 0 → 1 | k = 320, c = 24 | física | — | foto con `data-cursor="label"` |
| Etiqueta | `rotate` | vx × 0,012, limitado a ±10° | continuo | — | — | velocidad del aro |
| Aro en reposo | estirado | `scale(1+s, 1−0,55·s)` orientado, s = min(|v|/2600, 0,3) | continuo | — | — | velocidad |
| Aro | escala de presión | 1 → 0,84 → 1 | muelle k = 260, c = 25 | física | — | `pointerdown` / `pointerup` |
| Aro, punto, etiqueta | `opacity` | 0 ↔ 1 | 0,25 s (etiqueta 0,2 s) | `linear` | — | entrar/salir del documento |
| Eco táctil | `scale` + `opacity` | 1 → 1,35 / 1 → 0 | 0,6 s | `cubic-bezier(.16,1,.3,1)` | — | toque en imán |
| Interruptor (bolita) | `translateX` | 16 px ↔ 0 | 0,35 s | `cubic-bezier(.16,1,.3,1)` | — | clic |
| Chips de temas | fondo y color | contorno ↔ tinta | 0,3 s | `ease` | — | clic |
Valores del estado del aro (objetivos de los muelles):
| Estado | Centro | Ancho × alto | Radio | Relleno | Punto | Etiqueta |
|---|---|---|---|---|---|---|
| Reposo | puntero | 38 × 38 | 19 | 0 | 1 | 0 |
| Imán | centro del elemento + su desplazamiento actual | (w + 12) × (h + 12) | radio del elemento + 6 | 1 | 0 | 0 |
| Lupa | puntero | 150 × 150 (o `data-cursor-size`) | 75 | 1 | 0 | 0 |
| Etiqueta | puntero | 12 × 12 (y aro oculto por opacidad) | 6 | 0 | 0 | 1 |
## Código clave
CSS completo (va justo después del bloque `:root`):
```css
*,*::before,*::after{box-sizing:border-box}
html{background:var(--fondo);-webkit-text-size-adjust:100%}
@media (prefers-reduced-motion: no-preference){html{scroll-behavior:smooth}}
body{margin:0;background:var(--fondo);color:var(--tinta);font:400 16px/1.55 var(--f-texto);-webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
em{font-style:italic}
:focus-visible{outline:2px solid var(--acento);outline-offset:4px;border-radius:4px}
/* ——— Cursor propio ——— */
html.cursor-on, html.cursor-on *{cursor:none!important}
.c-aro,.c-punto,.c-etiqueta,.c-eco{position:fixed;left:0;top:0;pointer-events:none;z-index:100;opacity:0;will-change:transform}
.c-aro{width:var(--aro);height:var(--aro);border:1.5px solid var(--cursor);border-radius:50%;mix-blend-mode:difference;transition:opacity .25s linear}
.c-relleno{position:absolute;inset:-1.5px;border-radius:inherit;background:var(--cursor);opacity:0}
.c-punto{width:6px;height:6px;border-radius:50%;background:var(--cursor);mix-blend-mode:difference;transition:opacity .25s linear}
.c-etiqueta{width:112px;height:112px;margin:-56px 0 0 -56px;border-radius:50%;background:var(--tinta);color:var(--fondo);display:grid;place-items:center;text-align:center;font:500 13px/1.15 var(--f-texto);letter-spacing:.01em;transition:opacity .2s linear}
.c-etiqueta span{display:block;max-width:80px}
.c-etiqueta svg{display:block;margin:6px auto 0;width:14px;height:14px}
html.cursor-visible .c-aro, html.cursor-visible .c-punto, html.cursor-visible .c-etiqueta{opacity:1}
html.modo-etiqueta .c-aro{opacity:0!important}
.c-eco{border:1.5px solid var(--cursor);mix-blend-mode:difference}
.c-eco.activo{animation:eco .6s var(--ease-out) forwards}
@keyframes eco{0%{opacity:1;transform:var(--t) scale(1)}100%{opacity:0;transform:var(--t) scale(1.35)}}
/* ——— Cabecera ——— */
.cabecera{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px var(--gutter)}
.marca{display:flex;align-items:baseline;gap:10px;padding:6px 2px}
.marca b{font:400 30px/1 var(--f-display);font-style:italic;letter-spacing:-.01em}
.marca small{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.nav{display:flex;align-items:center;gap:6px}
.nav ul{display:flex;gap:2px;margin:0 18px 0 0;padding:0;list-style:none}
.nav ul a{display:inline-block;padding:10px 14px;font-size:15px;font-weight:500}
.mag-in{display:inline-flex;align-items:center;gap:10px;will-change:transform}
/* ——— Botones ——— */
.btn{display:inline-flex;align-items:center;height:52px;padding:0 26px;border-radius:999px;font-size:15px;font-weight:500;white-space:nowrap;will-change:transform}
.btn svg{width:16px;height:16px;flex:none}
.btn-lleno{background:var(--tinta);color:var(--fondo)}
.btn-linea{box-shadow:inset 0 0 0 1.5px var(--tinta)}
.btn-peq{height:44px;padding:0 20px}
/* ——— Portada ——— */
.portada{position:relative;min-height:100svh;display:grid;grid-template-rows:auto 1fr auto}
.portada-cuerpo{padding:clamp(8px,2svh,28px) var(--gutter) 0;display:flex;flex-direction:column;justify-content:center}
.antetitulo{display:flex;align-items:center;gap:12px;margin:0 0 clamp(14px,2.4svh,28px);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.antetitulo::before{content:"";width:28px;height:1.5px;background:currentColor}
.titular{margin:0;font:400 clamp(3.4rem,10.4vw,10.5rem)/.9 var(--f-display);letter-spacing:-.025em}
.titular .l{display:block;overflow:hidden;padding:.02em 0 .1em;margin-bottom:-.1em}
.titular .l > span{display:inline-block}
.titular .l2{padding-left:15vw}
.titular .l3{padding-left:30vw}
.titular em{color:var(--acento);padding-right:.04em}
.sello{position:absolute;left:calc(77.7% - 84px);top:calc(64% - 84px);width:168px;height:168px;border-radius:50%;background:var(--tinta);color:var(--fondo);display:grid;place-items:center;will-change:transform}
.sello-texto{position:absolute;inset:0;width:100%;height:100%;animation:giro 24s linear infinite}
.sello-texto text{font:500 12.5px var(--f-texto);letter-spacing:.2em;fill:currentColor}
.sello-flecha svg{width:34px;height:34px}
@keyframes giro{to{transform:rotate(360deg)}}
.portada-pie{display:flex;align-items:flex-end;justify-content:space-between;gap:32px;padding:clamp(20px,4svh,44px) var(--gutter) clamp(22px,4svh,40px)}
.entrada{max-width:440px}
.entrada p{margin:0 0 22px;font-size:17px;color:var(--tinta-suave)}
.acciones{display:flex;flex-wrap:wrap;gap:10px}
.meta{display:flex;flex-direction:column;align-items:flex-end;gap:12px;text-align:right;font-size:13px;color:var(--tinta-suave)}
.coordenadas{font-variant-numeric:tabular-nums;letter-spacing:.06em}
.pista{display:flex;align-items:center;gap:10px}
.pista i{width:8px;height:8px;border-radius:50%;background:var(--acento);animation:latido 2.2s var(--ease-io) infinite}
@keyframes latido{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.55);opacity:.5}}
.pista .tactil{display:none}
.interruptor{display:inline-flex;align-items:center;gap:10px;padding:8px 4px 8px 12px;font-size:13px;font-weight:500;color:var(--tinta)}
.interruptor .pildora{position:relative;width:38px;height:22px;border-radius:99px;background:var(--tinta);transition:background .3s var(--ease-io)}
.interruptor .pildora::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--fondo);transform:translateX(16px);transition:transform .35s var(--ease-out)}
.interruptor[aria-pressed="false"] .pildora{background:color-mix(in srgb, var(--tinta) 35%, transparent)}
.interruptor[aria-pressed="false"] .pildora::after{transform:none}
/* ——— Trabajo ——— */
.seccion{padding:clamp(72px,12svh,140px) var(--gutter)}
.seccion-cab{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding-bottom:28px;margin-bottom:clamp(28px,5svh,56px);border-bottom:1px solid var(--linea)}
.seccion-cab .antetitulo{margin-bottom:14px}
.seccion h2{margin:0;font:400 clamp(2.6rem,6vw,5.6rem)/.95 var(--f-display);letter-spacing:-.02em}
.enlace-flecha{display:inline-flex;align-items:center;padding:10px 4px;font-size:15px;font-weight:500;border-bottom:1.5px solid currentColor;will-change:transform}
.enlace-flecha svg{width:14px;height:14px}
.obras{display:grid;grid-template-columns:repeat(12,1fr);align-items:start;gap:clamp(28px,4vw,56px) clamp(16px,2.2vw,32px)}
.obra{display:block;grid-column:span 7}
.obra:nth-child(1),.obra:nth-child(4){grid-column:span 5}
.obra:nth-child(2),.obra:nth-child(4){margin-top:clamp(32px,8vw,120px)}
.marco{position:relative;aspect-ratio:4/3;overflow:hidden;border-radius:4px;isolation:isolate}
.obra:nth-child(1) .marco,.obra:nth-child(4) .marco{aspect-ratio:4/5}
.img{position:absolute;inset:0;transition:transform 1.1s var(--ease-out)}
.marco::after{content:"";position:absolute;inset:0;background:var(--grano);opacity:.38;mix-blend-mode:multiply;pointer-events:none}
@media (hover:hover){.obra:hover .img{transform:scale(1.045)}}
.rotulo{position:absolute;left:14px;bottom:14px;z-index:2;padding:5px 10px;border-radius:99px;background:color-mix(in srgb, var(--tinta) 62%, transparent);color:#f3eee6;font-size:11.5px;letter-spacing:.04em}
.chip-tactil{position:absolute;right:14px;top:14px;z-index:2;display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:99px;background:var(--tinta);color:var(--fondo);font-size:12.5px;font-weight:500;opacity:0;transition:opacity .3s}
.chip-tactil svg{width:11px;height:11px}
html:not(.cursor-on) .chip-tactil{opacity:1}
.pie-obra{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding-top:16px}
.pie-obra h3{margin:0;font:400 clamp(1.6rem,2.2vw,2.1rem)/1.05 var(--f-display)}
.pie-obra p{margin:0;font-size:13.5px;color:var(--tinta-suave);text-align:right}
.pie-obra .n{font-variant-numeric:tabular-nums}
/* Fotos procedurales (duotono + grano) */
.f-mar .img{background:
radial-gradient(circle at 63% 49%, #f6e3c8 0 7%, rgba(246,227,200,.0) 7.4%),
radial-gradient(120% 70% at 63% 52%, rgba(247,206,160,.75), rgba(247,206,160,0) 60%),
linear-gradient(#e8c29a 0%, #d98b5e 52%, #2e4957 52.2%, #1a2932 100%)}
.f-mar .img::before{content:"";position:absolute;left:0;right:0;top:52.2%;bottom:0;background:repeating-linear-gradient(180deg, rgba(246,220,190,.22) 0 1px, transparent 1px 9px);-webkit-mask:radial-gradient(40% 60% at 63% 0, #000, transparent);mask:radial-gradient(40% 60% at 63% 0, #000, transparent)}
.f-cal .img{background:linear-gradient(118deg, #f1ebe1 0 52%, #d9cdbb 52% 100%)}
.f-cal .arco{position:absolute;left:30%;width:40%;top:22%;bottom:0;border-radius:999px 999px 0 0;background:linear-gradient(180deg,#43342a,#2a211b)}
.f-cal .arco::after{content:"";position:absolute;inset:auto 0 0 0;height:22%;background:linear-gradient(118deg, rgba(241,235,225,.18) 0 40%, transparent 40%)}
.f-cal .img::after{content:"";position:absolute;left:0;right:0;bottom:0;height:9%;background:#b7a58d}
.f-barro .img{background:linear-gradient(105deg, rgba(255,226,196,0) 30%, rgba(255,226,196,.16) 30% 52%, rgba(255,226,196,0) 52%) 0 0/100% 66% no-repeat, linear-gradient(#b8664a 0 66%, #e3d8ca 66% 100%)}
.f-barro .vasija{position:absolute;left:27%;width:46%;top:34%;height:36%;border-radius:50% 50% 44% 44% / 64% 64% 36% 36%;background:radial-gradient(60% 55% at 36% 34%, #e6a883, #a4512f 55%, #6f3019)}
.f-barro .vasija::before{content:"";position:absolute;left:39%;width:22%;top:-15%;height:20%;border-radius:6px 6px 2px 2px;background:linear-gradient(90deg,#8b4024,#c0714c 45%,#7a361e)}
.f-barro .vasija::after{content:"";position:absolute;left:8%;right:8%;bottom:-10%;height:12%;border-radius:50%;background:rgba(60,26,14,.35);filter:blur(6px);z-index:-1}
.f-hora .img{background:
radial-gradient(circle at 28% 40%, #fbe1a6 0 6%, rgba(251,225,166,0) 6.4%),
radial-gradient(140% 90% at 30% 110%, #4f5536 0 48%, transparent 48.3%),
radial-gradient(120% 80% at 88% 100%, #6e7447 0 50%, transparent 50.3%),
linear-gradient(#f0c677, #e0914c 70%, #c77a3e)}
.f-hora .img::before{content:"";position:absolute;inset:55% 0 0 0;background:repeating-linear-gradient(100deg, rgba(20,22,10,.22) 0 2px, transparent 2px 22px);-webkit-mask:linear-gradient(transparent, #000 40%);mask:linear-gradient(transparent, #000 40%)}
/* ——— Contacto ——— */
.contacto{padding:clamp(72px,12svh,140px) var(--gutter) 28px;border-top:1px solid var(--linea)}
.contacto-cab{display:flex;justify-content:space-between;align-items:flex-end;gap:32px;margin-bottom:40px}
.contacto-cab > p{max-width:340px;margin:0 0 10px;font-size:16px;color:var(--tinta-suave)}
.contacto h2{margin:0;font:400 clamp(2.8rem,7.4vw,7rem)/.95 var(--f-display);letter-spacing:-.02em}
.contacto h2 em{color:var(--acento)}
.temas-tit{margin:0 0 12px;font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.temas{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:clamp(40px,8svh,88px)}
.tema{height:42px;padding:0 18px;border-radius:999px;box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--tinta) 40%, transparent);font-size:14.5px;font-weight:500;transition:background .3s,color .3s;will-change:transform}
.tema[aria-pressed="true"]{background:var(--tinta);color:var(--fondo);box-shadow:none}
.correo{display:inline-block;font:400 clamp(2.4rem,8.6vw,8.4rem)/1 var(--f-display);letter-spacing:-.03em;padding:.06em .02em .12em;word-break:break-word;will-change:transform}
.datos{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-top:clamp(36px,6svh,64px);padding-top:28px;border-top:1px solid var(--linea)}
.datos h3{margin:0 0 8px;font-size:12px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.datos p{margin:0;font-size:16px}
.datos a{display:inline-block;padding:4px 0;font-weight:500;will-change:transform}
.redes{display:flex;flex-wrap:wrap;gap:4px 18px}
.final{display:flex;justify-content:space-between;gap:16px;margin-top:clamp(48px,9svh,96px);font-size:13px;color:var(--tinta-suave)}
/* ——— Entrada ——— */
@media (prefers-reduced-motion: no-preference){
.titular .l > span{animation:sube 1.15s var(--ease-out) both}
.titular .l2 > span{animation-delay:.09s}
.titular .l3 > span{animation-delay:.18s}
.antetitulo,.portada-pie,.sello,.cabecera{animation:aparece .9s var(--ease-out) .35s both}
.cabecera{animation-delay:.1s}
}
@keyframes sube{from{transform:translateY(105%)}to{transform:none}}
@keyframes aparece{from{opacity:0}to{opacity:1}}
/* ——— Táctil ——— */
@media (hover:none), (pointer:coarse){
.pista .raton{display:none}
.pista .tactil{display:inline}
.interruptor{display:none}
}
/* ——— Tablet ——— */
@media (max-width:1024px){
.nav ul{display:none}
.sello{width:140px;height:140px;left:auto;right:var(--gutter);top:auto;bottom:calc(100% + 24px)}
.portada-pie{position:relative}
.titular .l3{padding-left:22vw}
.titular{font-size:13vw}
}
/* ——— Móvil ——— */
@media (max-width:720px){
.cabecera{padding-top:16px;padding-bottom:16px}
.marca small{display:none}
.portada-cuerpo{justify-content:flex-start;padding-top:9svh}
.titular{font-size:clamp(3.2rem,17.5vw,5.4rem)}
.correo{font-size:12.4vw}
.contacto-cab{flex-direction:column;align-items:flex-start;gap:20px}
.titular .l2{padding-left:9vw}
.titular .l3{padding-left:18vw}
.portada-pie{display:grid;grid-template-columns:1fr auto;align-items:end;gap:28px 16px}
.entrada{grid-column:1 / -1}
.sello{position:relative;inset:auto;grid-column:2;grid-row:2;width:112px;height:112px}
.sello-texto text{font-size:13px}
.sello-flecha svg{width:26px;height:26px}
.meta{grid-column:1;grid-row:2;align-items:flex-start;text-align:left}
.interruptor{padding-left:0}
.entrada p{font-size:16px}
.btn{height:50px;padding:0 22px}
.obras{grid-template-columns:1fr;gap:40px}
.obra,.obra:nth-child(n){grid-column:auto;margin-top:0}
.obra:nth-child(1) .marco,.obra:nth-child(4) .marco{aspect-ratio:4/3}
.seccion-cab{flex-direction:column;align-items:flex-start}
.datos{grid-template-columns:1fr}
.final{flex-direction:column}
}
@media (max-width:380px){.acciones .btn{flex:1 1 auto;justify-content:center}}
@media (prefers-reduced-motion: reduce){
.sello-texto,.pista i{animation:none}
.img{transition:none}
}
```
JS completo (un `<script>` clásico al final del `body`):
```js
(() => {
const ETIQUETA_POR_DEFECTO = "Ver proyecto";
const AJUSTES = {
aro: 38, // diámetro del aro en reposo (px)
lupa: 150, // diámetro del aro sobre titulares (px)
margen: 6, // holgura del aro alrededor del elemento imantado (px)
fuerza: 0.3, // cuánto sigue el elemento al puntero (0–1)
fuerzaInterior: 0.5, // el contenido se mueve un 50 % extra
muelleAro: { k: 340, c: 30 },
muelleForma: { k: 260, c: 25 },
muelleIman: { k: 170, c: 13 },
muelleEtiqueta: { k: 320, c: 24 }
};
const html = document.documentElement;
const fino = matchMedia("(hover: hover) and (pointer: fine)");
const reducido = matchMedia("(prefers-reduced-motion: reduce)");
const aro = document.querySelector(".c-aro");
const relleno = document.querySelector(".c-relleno");
const punto = document.querySelector(".c-punto");
const etiqueta = document.querySelector(".c-etiqueta");
const etiquetaTexto = etiqueta.querySelector("span");
const eco = document.querySelector(".c-eco");
const interruptor = document.getElementById("interruptor");
class Muelle {
constructor(v, p) { this.x = v; this.t = v; this.v = 0; this.k = p.k; this.c = p.c; }
paso(dt) {
if (reducido.matches) { this.x = this.t; this.v = 0; return 0; }
const a = this.k * (this.t - this.x) - this.c * this.v;
this.v += a * dt;
this.x += this.v * dt;
return Math.abs(this.t - this.x) + Math.abs(this.v) * 0.01;
}
fijar(v) { this.x = this.t = v; this.v = 0; }
}
const A = AJUSTES;
const ax = new Muelle(0, A.muelleAro), ay = new Muelle(0, A.muelleAro);
const aw = new Muelle(A.aro, A.muelleForma), ah = new Muelle(A.aro, A.muelleForma);
const ar = new Muelle(A.aro / 2, A.muelleForma), af = new Muelle(0, A.muelleForma);
const ap = new Muelle(1, A.muelleForma), ps = new Muelle(1, A.muelleForma);
const es = new Muelle(0, A.muelleEtiqueta);
const muelles = [ax, ay, aw, ah, ar, af, ap, ps, es];
const imanes = [...document.querySelectorAll("[data-magnet]")].map((el) => ({
el,
interior: el.querySelector(".mag-in"),
fuerza: parseFloat(el.dataset.magnet) || A.fuerza,
tx: new Muelle(0, A.muelleIman),
ty: new Muelle(0, A.muelleIman),
r: null
}));
let activo = false, visible = false, px = -100, py = -100, primera = true;
let estado = { modo: "reposo" }, sucio = true, elegir = true, corriendo = false, ultimo = 0;
// Radio real del elemento en px (acepta "999px" y "50%").
function radioDe(el, ancho) {
const r = getComputedStyle(el).borderTopLeftRadius;
return r.endsWith("%") ? (parseFloat(r) / 100) * ancho : parseFloat(r) || 0;
}
function medir() {
for (const m of imanes) {
const b = m.el.getBoundingClientRect();
const x = b.left - m.tx.x, y = b.top - m.ty.x;
const rad = radioDe(m.el, b.width);
m.r = {
x, y, w: b.width, h: b.height, cx: x + b.width / 2, cy: y + b.height / 2,
radio: rad > 0 ? Math.min(rad, b.height / 2) : b.height / 2,
zona: Math.max(12, Math.min(40, Math.min(b.width, b.height) * 0.35)),
vivo: b.width > 0 && b.height > 0
};
}
sucio = false;
}
function buscar() {
let mejor = null, dMejor = Infinity;
for (const m of imanes) {
const r = m.r;
if (!r || !r.vivo) continue;
const dx = Math.max(r.x - r.zona - px, 0, px - (r.x + r.w + r.zona));
const dy = Math.max(r.y - r.zona - py, 0, py - (r.y + r.h + r.zona));
if (dx === 0 && dy === 0) {
const d = Math.hypot(px - r.cx, py - r.cy);
if (d < dMejor) { dMejor = d; mejor = m; }
}
}
if (mejor) return { modo: "iman", m: mejor };
const el = document.elementFromPoint(px, py)?.closest("[data-cursor]");
if (el?.dataset.cursor === "label") return { modo: "etiqueta", texto: el.dataset.cursorText || ETIQUETA_POR_DEFECTO };
if (el?.dataset.cursor === "lens") return { modo: "lupa", d: parseFloat(el.dataset.cursorSize) || A.lupa };
return { modo: "reposo" };
}
function despertar() {
if (!corriendo && activo && !document.hidden) { corriendo = true; ultimo = performance.now(); requestAnimationFrame(cuadro); }
}
function cuadro(ahora) {
const dt = Math.min((ahora - ultimo) / 1000, 1 / 30) || 1 / 60;
ultimo = ahora;
if (sucio) medir();
if (elegir) {
const nuevo = buscar();
if (nuevo.modo === "etiqueta" && estado.texto !== nuevo.texto) etiquetaTexto.textContent = nuevo.texto;
estado = nuevo;
elegir = false;
html.classList.toggle("modo-etiqueta", estado.modo === "etiqueta");
}
const quieto = reducido.matches;
let energia = 0;
for (const m of imanes) {
const on = estado.modo === "iman" && estado.m === m && !quieto;
m.tx.t = on ? (px - m.r.cx) * m.fuerza : 0;
m.ty.t = on ? (py - m.r.cy) * m.fuerza : 0;
energia += m.tx.paso(dt) + m.ty.paso(dt);
const x = m.tx.x, y = m.ty.x;
if (Math.abs(x) < 0.01 && Math.abs(y) < 0.01 && !on) {
if (m.el.style.transform) { m.el.style.transform = ""; if (m.interior) m.interior.style.transform = ""; }
} else {
m.el.style.transform = `translate3d(${x.toFixed(2)}px,${y.toFixed(2)}px,0)`;
if (m.interior) m.interior.style.transform = `translate3d(${(x * A.fuerzaInterior).toFixed(2)}px,${(y * A.fuerzaInterior).toFixed(2)}px,0)`;
}
}
if (estado.modo === "iman") {
const m = estado.m, r = m.r, g = A.margen;
ax.t = r.cx + m.tx.x; ay.t = r.cy + m.ty.x;
aw.t = r.w + g * 2; ah.t = r.h + g * 2; ar.t = r.radio + g; af.t = 1; ap.t = 0; es.t = 0;
} else if (estado.modo === "lupa") {
ax.t = px; ay.t = py; aw.t = ah.t = estado.d; ar.t = estado.d / 2; af.t = 1; ap.t = 0; es.t = 0;
} else if (estado.modo === "etiqueta") {
ax.t = px; ay.t = py; aw.t = ah.t = 12; ar.t = 6; af.t = 0; ap.t = 0; es.t = 1;
} else {
ax.t = px; ay.t = py; aw.t = ah.t = A.aro; ar.t = A.aro / 2; af.t = 0; ap.t = 1; es.t = 0;
}
for (const s of muelles) energia += s.paso(dt);
// Estirado según la velocidad (solo en reposo)
let estira = 0, ang = 0;
if (estado.modo === "reposo" && !quieto) {
const v = Math.hypot(ax.v, ay.v);
estira = Math.min(v / 2600, 0.3);
ang = Math.atan2(ay.v, ax.v) * 57.2958;
}
const w = Math.max(aw.x, 0), h = Math.max(ah.x, 0);
aro.style.width = w.toFixed(2) + "px";
aro.style.height = h.toFixed(2) + "px";
aro.style.borderRadius = Math.max(ar.x, 0).toFixed(2) + "px";
aro.style.transform = `translate3d(${(ax.x - w / 2).toFixed(2)}px,${(ay.x - h / 2).toFixed(2)}px,0) rotate(${ang.toFixed(1)}deg) scale(${((1 + estira) * ps.x).toFixed(3)},${((1 - estira * 0.55) * ps.x).toFixed(3)}) rotate(${(-ang).toFixed(1)}deg)`;
relleno.style.opacity = Math.min(Math.max(af.x, 0), 1).toFixed(3);
const sp = Math.min(Math.max(ap.x, 0), 1.2);
punto.style.transform = `translate3d(${px - 3}px,${py - 3}px,0) scale(${sp.toFixed(3)})`;
const inclina = quieto ? 0 : Math.max(-10, Math.min(10, ax.v * 0.012));
etiqueta.style.transform = `translate3d(${ax.x.toFixed(2)}px,${ay.x.toFixed(2)}px,0) rotate(${inclina.toFixed(2)}deg) scale(${Math.max(es.x, 0).toFixed(3)})`;
if (energia > 0.02 || elegir) requestAnimationFrame(cuadro);
else corriendo = false;
}
function mostrar(v) { visible = v; html.classList.toggle("cursor-visible", v && activo); }
function alMover(e) {
if (!activo || e.pointerType === "touch") return;
px = e.clientX; py = e.clientY;
if (primera) { ax.fijar(px); ay.fijar(py); primera = false; }
if (!visible) mostrar(true);
elegir = true;
despertar();
}
function soltarImanes() {
for (const m of imanes) { m.tx.fijar(0); m.ty.fijar(0); m.el.style.transform = ""; if (m.interior) m.interior.style.transform = ""; }
}
function activar(on) {
activo = on;
html.classList.toggle("cursor-on", on);
interruptor.setAttribute("aria-pressed", String(on));
if (!on) { mostrar(false); soltarImanes(); estado = { modo: "reposo" }; html.classList.remove("modo-etiqueta"); primera = true; }
else { sucio = true; }
}
addEventListener("pointermove", alMover, { passive: true });
addEventListener("pointerdown", (e) => {
if (activo && e.pointerType !== "touch") { ps.t = 0.84; despertar(); }
else if (e.pointerType === "touch" && !reducido.matches) {
const el = e.target.closest("[data-magnet]");
if (!el) return;
const b = el.getBoundingClientRect(), g = 6;
const rad = radioDe(el, b.width);
eco.style.width = b.width + g * 2 + "px";
eco.style.height = b.height + g * 2 + "px";
eco.style.borderRadius = (rad > 0 ? Math.min(rad, b.height / 2) : b.height / 2) + g + "px";
eco.style.setProperty("--t", `translate3d(${b.left - g}px,${b.top - g}px,0)`);
eco.classList.remove("activo"); void eco.offsetWidth; eco.classList.add("activo");
}
}, { passive: true });
addEventListener("pointerup", () => { ps.t = 1; despertar(); }, { passive: true });
document.addEventListener("mouseout", (e) => { if (!e.relatedTarget) mostrar(false); });
document.addEventListener("mouseover", () => { if (activo && !primera) mostrar(true); });
addEventListener("blur", () => mostrar(false));
addEventListener("scroll", () => { sucio = true; elegir = true; despertar(); }, { passive: true });
addEventListener("resize", () => { sucio = true; despertar(); });
document.addEventListener("visibilitychange", () => { if (!document.hidden) despertar(); });
if (document.fonts) document.fonts.ready.then(() => { sucio = true; });
interruptor.addEventListener("click", () => activar(!activo));
fino.addEventListener("change", () => activar(fino.matches));
activar(fino.matches);
// Demo: los enlaces vacíos no navegan; los temas se marcan y desmarcan.
document.querySelectorAll('a[href="#"]').forEach((a) => a.addEventListener("click", (e) => e.preventDefault()));
document.querySelectorAll(".tema").forEach((b) => b.addEventListener("click", () => b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"))));
// El sello solo gira cuando se ve.
const sello = document.querySelector(".sello-texto");
new IntersectionObserver(([en]) => { sello.style.animationPlayState = en.isIntersecting ? "running" : "paused"; }).observe(sello);
})();
```
Detalles del código que no se pueden cambiar:
- El muelle es un Euler semiimplícito: `a = k·(t − x) − c·v; v += a·dt; x += v·dt`, con `dt` limitado a 1/30 s. Con movimiento reducido, `x = t` al instante.
- Los rectángulos de los imanes se miden **restando su desplazamiento actual** (`b.left − m.tx.x`), para que el imán no se persiga a sí mismo. Se vuelven a medir en `scroll`, `resize` y cuando cargan las fuentes.
- La zona de captura de cada imán es `clamp(12, min(w, h) · 0,35, 40)` px alrededor de su caja; si hay varios, gana el de centro más cercano. Los imanes tienen prioridad sobre la lupa y la etiqueta, que se detectan con `document.elementFromPoint(...).closest("[data-cursor]")`.
- El radio de un imán se lee de `getComputedStyle(...).borderTopLeftRadius`, admitiendo `%` (el sello tiene `50%`); si es 0 (enlaces de texto), se usa `alto / 2` para que el aro sea una píldora.
- En `scroll` se vuelve a elegir estado aunque el ratón no se mueva: al hacer scroll con el puntero quieto, el cursor reacciona a lo que pasa por debajo.
- El bucle solo corre mientras la suma de energía (distancias + velocidades) supera 0,02; en reposo no hay ningún `requestAnimationFrame` pendiente.
## Interacción
- **Puntero fino** (`(hover: hover) and (pointer: fine)`): se añade `html.cursor-on`, que oculta el cursor nativo en todo el documento. El cursor aparece con el primer `pointermove` (el aro nace ya en el puntero, sin volar desde 0,0) y se oculta al salir del documento (`mouseout` sin `relatedTarget`) o al perder el foco la ventana.
- **Imán**: botones de la cabecera, «Ver trabajos», «Pedir presupuesto», sello (fuerza 0,4), marca (0,25), «Ver archivo completo», chips, correo (0,12), teléfono y redes.
- **Lupa**: las tres líneas del titular.
- **Etiqueta**: las cuatro obras; el texto sale de `data-cursor-text` y, si falta, de «Ver proyecto».
- **Clic**: el aro se comprime a 0,84 mientras se mantiene pulsado. Los enlaces `href="#"` de la demo no navegan (`preventDefault`); los chips alternan `aria-pressed`.
- **Scroll**: sin librerías; `scroll-behavior: smooth` salvo con movimiento reducido.
- **Teclado**: todos los controles son `a` o `button` nativos con `:focus-visible` (contorno de 2 px color acento, separación 4 px). El cursor no interfiere con el foco.
- **Táctil** (`hover: none` o `pointer: coarse`): no se crea el cursor ni se mueve nada. Las fotos enseñan su etiqueta como chip tinta arriba a la derecha (`html:not(.cursor-on) .chip-tactil { opacity: 1 }`). Al tocar un elemento imantado se coloca `.c-eco` sobre su caja (6 px de holgura, su mismo radio) y se reinicia su animación de 0,6 s. La pista cambia a «En táctil, toca un botón y verás su eco» y el interruptor se oculta.
- **Interruptor «Cursor propio»**: alterna el cursor; al apagarlo se devuelve el cursor nativo, se sueltan los imanes y aparecen los chips de las fotos.
- Si cambia el tipo de puntero (se conecta un ratón a una tableta), `matchMedia(...).addEventListener("change")` activa o desactiva el cursor.
## Adaptación a móvil y tablet
- **> 1024 px**: todo como se describe; el sello flota a la derecha del titular.
- **≤ 1024 px**: se oculta la lista del nav (quedan marca y «Hablemos»); el titular pasa a `13vw` y la tercera línea a sangría 22vw; el sello mide 140 px y se coloca a la derecha, justo encima del pie de portada (`bottom: calc(100% + 24px)`).
- **≤ 720 px**: cabecera con 16 px de relleno vertical y sin «ESTUDIO»; el cuerpo de portada arranca arriba con `padding-top: 9svh`; titular `clamp(3.2rem, 17.5vw, 5.4rem)` con sangrías 9vw y 18vw; el pie de portada es una rejilla de 2 columnas: el texto y los botones ocupan todo el ancho y debajo quedan la meta (izquierda, alineada a la izquierda) y el sello de 112 px (derecha); obras en una columna, todas 4:3 y sin bajadas; cabeceras de sección apiladas; datos de contacto en una columna; correo a `12.4vw`; botones de 50 px de alto.
- **≤ 380 px**: los dos botones de la portada se estiran para ocupar la fila.
- Nada provoca scroll horizontal de 360 a 1920 px (`overflow-x: hidden` en `body`, `word-break` en el correo). Alturas con `svh`, nunca `100vh`.
## Accesibilidad y movimiento reducido
- HTML semántico: `header`, `nav` con `aria-label="Principal"`, `main`, secciones con `aria-labelledby`, `h1` único, `h2`/`h3` en orden, `footer`.
- Todo lo decorativo lleva `aria-hidden="true"`: los cuatro elementos del cursor, los SVG de flechas, el texto circular del sello (el enlace del sello tiene `aria-label="Bajar a los trabajos"`) y los marcos de foto con sus rótulos.
- Chips con `aria-pressed` dentro de un `role="group"` etiquetado; el interruptor es un `button` con `aria-pressed`.
- Contraste AA en todo el texto (ver tokens). Los rótulos de foto van sobre una píldora de tinta al 62 % con texto `#f3eee6`.
- `prefers-reduced-motion: reduce`: sin entrada animada del titular, sin giro del sello, sin latido de la pista, sin zoom de las fotos y sin `scroll-behavior: smooth`. En JS los muelles saltan al objetivo al instante, los elementos imantados **no se desplazan**, el aro no se estira y la etiqueta no se inclina: el cursor sigue cambiando de forma (imán, lupa, etiqueta), pero sin movimiento añadido. El eco táctil tampoco se dibuja.
## Rendimiento
- Sin WebGL (DPR y fallback no aplican). Solo se animan `transform` y `opacity`; el aro cambia `width`/`height`/`border-radius`, que en un elemento fijo sin hijos es barato.
- El bucle `requestAnimationFrame` arranca con `despertar()` en `pointermove`, `pointerdown`, `pointerup`, `scroll` y `resize`, y se para cuando la energía baja de 0,02. No corre con la pestaña oculta ni con el cursor desactivado.
- Medición de cajas solo cuando algo lo invalida (`sucio = true`), al principio del fotograma, antes de escribir estilos (sin *layout thrashing*).
- La animación CSS del sello se pausa con un `IntersectionObserver` cuando sale de pantalla.
- `will-change: transform` solo en el cursor y en los elementos imantados. Un solo archivo de unos 35 KB.
- Funciona dentro de un `iframe`: nada de `window.top`, `alert` ni almacenamiento.
## Personalización
- **Marca y datos**: `Salitre`, `Valmont`, `hola@salitre.com` y `+00 000 000 000` aparecen en título, marca, antetítulo, párrafo de entrada, contacto y pie. Si cambias de ciudad, actualiza también las coordenadas «40°00′ N · 10°00′ E», el barrio «Barrio del Puerto» y, si quieres, los rótulos de las fotos.
- **Colores**: cambia solo `--fondo` (#ece6dc), `--tinta` (#16140f) y `--acento` (#a63f1e); todo lo demás deriva de ellos con `color-mix`. El cursor usa el color del fondo con `difference`, así que el negativo se recalcula solo. Con un fondo oscuro la pieza funciona igual: el aro se verá claro sobre el fondo.
- **Texto de la etiqueta**: `Ver proyecto` es el valor por defecto; cada foto puede llevar el suyo en `data-cursor-text`.
- **Sensación del imán**: `AJUSTES.fuerza` (0,3) para todos, o `data-magnet="0.4"` por elemento; `AJUSTES.margen` (6 px) para la holgura; `muelleIman` más rígido (k 240, c 20) para un imán seco, más blando (k 120, c 9) para uno gomoso.
- **Tamaños**: `AJUSTES.aro` (38) y `AJUSTES.lupa` (150); una lupa distinta por elemento con `data-cursor-size="200"`.
- **Reutilizar en otra web**: copia los cuatro elementos `.c-*`, el bloque CSS «Cursor propio» y el script; marca tus elementos con `data-magnet`, `data-cursor="lens"` o `data-cursor="label"` + `data-cursor-text`.
## Comprobación final
1. Con ratón, el cursor nativo no se ve en ningún sitio y el aro aparece en el puntero al primer movimiento, sin viajar desde la esquina.
2. El aro sigue al punto con retraso y rebote leve, y se estira en la dirección del movimiento al ir rápido.
3. Al acercarte a «Ver trabajos», el botón se desplaza hacia el puntero, su texto un poco más, y el aro lo envuelve como una píldora rellena que lo muestra en negativo con un halo negro de 6 px.
4. Sobre el sello, el aro es un círculo perfecto (no un cuadrado redondeado) y el sello se mueve con fuerza 0,4.
5. Al salir de un imán, el elemento vuelve a su sitio con un rebote visible.
6. Sobre el titular aparece una lupa de 150 px que vuelve negro el fondo y turquesa la palabra «atraen».
7. Sobre cada foto aparece un disco tinta de 112 px con su texto («Abrir serie», «Ver proyecto», «Ver campaña», «Ver piezas»), que entra escalando y se inclina al moverlo; la foto hace un zoom lento a 1,045.
8. Haciendo scroll con la rueda y el ratón quieto, el cursor cambia de estado según lo que pasa por debajo.
9. Con el puntero quieto, no queda ningún `requestAnimationFrame` en marcha.
10. En móvil real no hay cursor, las fotos muestran su chip de etiqueta y tocar un botón dibuja el eco.
11. El interruptor «Cursor propio» apaga y enciende el efecto y devuelve el cursor nativo.
12. Con movimiento reducido no hay entradas, giros ni desplazamientos de botones, y todo es legible desde el primer fotograma.
13. Se navega todo con Tab y el foco se ve (contorno acento de 2 px).
14. Sin scroll horizontal a 360, 390, 768, 1024, 1440 y 1920 px, y cero errores en consola.Prueba en frío
Pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos el resultado con la demo. Prueba hecha el 30-09-2026.

Prácticamente un calco: el archivo coincide con el original salvo dos líneas en blanco, así que maquetación, lupa invertida, etiqueta, imán con muelles y versión móvil salen idénticos.

El cursor funciona como en el original (muelles, imán, lupa que vuelve turquesa «atraen», etiqueta sobre las fotos) y la portada se parece mucho, pero el sello cae encima del texto de la pista, las fotos son otras composiciones con otros rótulos y en contacto faltan el párrafo, el rótulo «Cuéntanos qué necesitas» y el chip activo.
Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.
Paso a paso
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.
Puedes
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 30-09-2026

Bloques de web · GSAP

Bloques de web · JS

Bloques de web · CSS

Bloques de web · CSS

Bloques de web · JS

Bloques de web · GSAP

Bloques de web · GSAP

Bloques de web · SVG