
Titular gigante
GratisBloques de web · GSAP
La pieza
Una pantalla partida en negro y papel con tres botones: un círculo, un icono sobre la costura y una píldora. Cada uno se estira hacia el cursor con un muelle que rebota un poco, mueve su etiqueta con paralaje y se rellena de naranja vivo con un recorte circular que nace en el punto de entrada; al salir, el color se encoge hacia el punto de salida. La flecha gira de → a ↗ con el relleno y un anillo discontinuo marca el campo del imán. El titular se invierte de color al cruzar la costura. Si nadie mueve el ratón, un cursor fantasma recorre los botones, así que también funciona como demostración en móvil. Sin librerías ni imágenes: CSS, SVG y unas 200 líneas de JS. Encaja en llamadas a la acción de landings, portfolios y webs de producto; cambia el color vivo, el negro y el papel o los textos para adaptarla a tu marca.
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: Botón magnético con relleno
Una pantalla partida en negro y papel con tres botones que se imantan al cursor, se estiran hacia él y se rellenan de naranja vivo desde el punto exacto por el que entras (y se vacían hacia el punto por el que sales); cuando nadie mueve el ratón, un cursor fantasma enseña la interacción solo.
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 (aquí no hace falta ninguna: todo es CSS y JS propio). Todos los valores numéricos de este documento son definitivos: úsalos tal cual. Responde solo con el archivo completo.
## Qué es
Una microinteracción a pantalla completa (100svh, mínimo 600 px) dividida en dos mitades: negro puro a la izquierda y papel cálido a la derecha (en móvil, arriba y abajo). Encima, un titular grande en grotesca moderna que **se invierte de color al cruzar la costura** (blanco sobre negro, negro sobre papel) gracias a `mix-blend-mode: difference`. Tres botones, todos con el mismo comportamiento:
- **A · Círculo** (mitad negra): botón circular de contorno blanco con «Hablemos» y una flecha. Al entrar el cursor se rellena de acento con texto negro.
- **C · Icono** (sobre la costura, en el centro exacto): botón redondo solo con flecha, contorno y flecha de acento. Se rellena de acento.
- **B · Píldora** (mitad papel): píldora de contorno negro con «Reservar cita» y un chip circular de acento con flecha. Se rellena de negro con texto papel; el chip se mantiene en acento.
El efecto, en cuatro piezas que trabajan a la vez:
1. **Imán**: el botón se desplaza hacia el cursor (35 % de la distancia) con un muelle que sobrepasa un poco y se asienta; la etiqueta se desplaza además un 50 % extra (paralaje).
2. **Relleno desde la entrada**: un recorte circular (`clip-path: circle()`) cuyo centro es el punto de entrada del cursor crece hasta cubrir el botón. Al salir, el centro pasa a ser el punto de salida y el círculo se encoge hacia él.
3. **Flecha que gira** −45° (de → a ↗) en proporción al relleno, y un anillo discontinuo («campo») que aparece alrededor del círculo cuando el cursor se acerca.
4. **Cursor fantasma**: si no hay puntero real (carga inicial, móvil, ratón quieto 4 s), un cursor dibujado recorre los botones: se acerca por un lado, entra, se mueve dentro y sale por el opuesto. Sirve de demostración en táctil.
Detalle de oficio: un pie con una lectura en vivo («imán x +00 y +00 · relleno 000 %») del botón activo.
## Librerías y esqueleto
Ninguna librería. Esqueleto del `<head>`, exacto:
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Botón magnético con relleno</title>
<meta name="description" content="Botones que se imantan al cursor y se rellenan de color desde el punto por el que entras.">
<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=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Geist:wght@400;500&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style> … hoja de estilos (ver «Código clave») … </style>
</head>
<body> … estructura (ver «Estructura y contenido») … <script> … script (ver «Código clave») … </script></body>
</html>
```
Tipografías: **Bricolage Grotesque** (display: titular, marca, botones; `font-variation-settings: "opsz" 96` en el titular), **Geist** (texto corrido) y **Geist Mono** (etiquetas, notas y lectura en vivo, en mayúsculas con tracking .07em). Las tres tienen ñ, tildes, ¿ y ¡. Sin cursivas serif ni letra manuscrita.
## Tokens de diseño
Bloque `:root` literal (blanco y negro con **un** color vivo; sin pasteles):
```css
:root{
--negro:#0b0b0c;
--papel:#f2f1ec;
--acento:#ff4b14;
--gris-oscuro:#a3a39d; /* texto secundario sobre negro */
--gris-claro:#5f5f5a; /* texto secundario sobre papel */
--f-display:"Bricolage Grotesque",system-ui,sans-serif;
--f-texto:"Geist",system-ui,sans-serif;
--f-mono:"Geist Mono",ui-monospace,monospace;
--ease-out:cubic-bezier(.16,1,.3,1);
--margen:clamp(20px,3.4vw,52px);
--d:clamp(150px,min(17vw,27vh),236px); /* círculo */
--di:clamp(70px,min(6.4vw,11vh),98px); /* icono */
--hp:clamp(74px,min(7.2vw,12vh),108px); /* alto de la píldora */
}
```
Los tamaños de los botones usan `min(vw, vh)` para que la composición no se rompa en pantallas anchas y bajas. Easing de entrada: `cubic-bezier(.16,1,.3,1)`.
## Estructura y contenido
Cuerpo literal. Todos los textos están en español de España. Las claves entre llaves dobles son las del personalizador.
```html
<main class="escena" id="escena">
<header class="inv barra">
<span class="marca">Polo</span>
<span class="mono">Micro 01 · Botón magnético</span>
</header>
<h1 class="inv titular"><span class="linea"><span>Un botón que</span></span><span class="linea"><span>viene a buscarte.</span></span></h1>
<section class="panel panel--oscuro" aria-label="Botón circular sobre fondo negro">
<div class="pos">
<span class="campo" aria-hidden="true"></span>
<button class="mag mag--circulo" type="button" data-pad="120">
<span class="mag__lab"><span>Hablemos</span><svg class="mag__arr" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
<span class="mag__over" aria-hidden="true"><span class="mag__lab"><span>Hablemos</span><svg class="mag__arr" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span></span>
</button>
</div>
<p class="nota mono"><b>A · Círculo</b><span class="det"><br>Se rellena desde donde entras.</span></p>
</section>
<section class="panel panel--claro" aria-label="Botón en píldora sobre fondo claro">
<div class="pos">
<button class="mag mag--pildora" type="button" data-pad="130">
<span class="mag__lab"><span>Reservar cita</span><span class="chip"><svg class="mag__arr" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span></span>
<span class="mag__over" aria-hidden="true"><span class="mag__lab"><span>Reservar cita</span><span class="chip"><svg class="mag__arr" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span></span></span>
</button>
</div>
<p class="intro">Se imanta al cursor, se estira hacia él y se rellena desde el punto exacto por el que entras. Un muelle, un recorte circular y nada más.</p>
<p class="nota mono"><b>B · Píldora</b><span class="det"><br>Y se vacía por donde sales.</span></p>
</section>
<div class="pos--centro">
<button class="mag mag--icono" type="button" aria-label="Siguiente" data-pad="110">
<span class="mag__lab"><svg class="mag__arr" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
<span class="mag__over" aria-hidden="true"><span class="mag__lab"><svg class="mag__arr" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span></span>
</button>
</div>
<footer class="inv base mono">
<span>Polo · Estudio de producto · Northbridge</span>
<span class="dato" aria-hidden="true" id="dato">imán x +00 y +00 · relleno 000 %</span>
</footer>
</main>
<div class="fantasma" id="fantasma" aria-hidden="true">
<svg width="26" height="32" viewBox="0 0 26 32"><path d="M2 2v24l6.4-6.2 4.1 9.4 4.2-1.8-4.1-9.3H21z" fill="#fff" stroke="#0b0b0c" stroke-width="2" stroke-linejoin="round"/></svg>
</div>
```
Notas sobre la estructura:
- Cada botón repite su etiqueta dos veces: la capa base (color de contorno) y `.mag__over` (capa de relleno, `aria-hidden`, recortada por `clip-path`). Las dos tienen **exactamente** la misma maquetación (`padding: inherit`, mismo `display:flex`, mismo gap) para que el texto no «salte» al cruzar el borde del relleno.
- El contorno del botón es un `box-shadow: inset 0 0 0 1.5px var(--line)` (no un `border`), así la capa de relleno lo cubre limpiamente.
- `.pos` (z-index 3) coloca cada botón por encima del titular invertido; `.pos--centro` es el botón de la costura (`left/top: 50%` y `translate: -50% -50%`).
- El `<span class="campo">` es el anillo discontinuo; solo el círculo lo lleva.
- `.det` es la segunda línea de cada nota (se oculta en móvil). `.intro` es el párrafo de la mitad papel (solo ≥ 1100 px).
## Movimiento
| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Línea 1 del titular | transform | translateY(112 %) → 0 | 1000 ms | cubic-bezier(.16,1,.3,1) | 100 ms | carga |
| Línea 2 del titular | transform | translateY(112 %) → 0 | 1000 ms | igual | 220 ms | carga |
| Botón círculo / icono / píldora | opacity | 0 → 1 | 700 ms | igual | 450 / 600 / 750 ms | carga |
| Botones | escala (muelle k 260, ζ .42) | 0,5 → 1 | muelle | — | 450 / 600 / 750 ms | carga |
| Notas, intro y lectura | opacity | 0 → 1 | 1000 ms | ease | 900 ms (intro 1000 ms) | carga |
| Botón (imán) | translate (muelles k 190, ζ .5) | 0 → (cursor − centro) × 0,35 × f | muelle | — | — | puntero cerca |
| Etiqueta | translate extra | 0 → desplazamiento del botón × 0,5 | muelle | — | — | igual |
| Botón | escala | 1 → 1,06 (hover) · 0,93 (pulsado) | muelle k 260, ζ .42 | — | — | entrar / pulsar |
| Botón | impulso | velocidad de escala += 4,5 | muelle | — | — | clic |
| Relleno | clip-path circle | radio 0 → R (esquina más lejana + 2 px) | muelle k 170, ζ 1 (≈ 0,4 s) | — | — | entrar / salir |
| Flecha | rotate | 0° → −45° × relleno | con el relleno | — | — | entrar / salir |
| Anillo de campo | opacity y scale | 0 → .55 (cerca) → 1 (dentro); .94 → 1 | suavizado exp. 9 / s | — | — | cercanía |
| Cursor fantasma | opacity | 0 → 1 | 300 ms | ease | — | sin puntero real |
Fantasma, ciclo por botón (orden de visita: círculo, icono, píldora, icono): viaje hasta un punto exterior (distancia / 0,8 × ancho de ventana por segundo, entre 0,7 y 1,5 s, easeInOut) → entrada hasta casi el centro (800 ms, easeOut cúbico) → deriva dentro (950 ms, easeInOut) → salida por el lado opuesto (700 ms, easeIn cuadrático). El ángulo de entrada avanza 2,15 rad en cada visita.
## Código clave
### Hoja de estilos completa
Todo lo que va dentro de `<style>` salvo el bloque `:root` ya dado:
```css
*{box-sizing:border-box;margin:0;padding:0}
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
body{background:var(--negro);color:var(--papel);font:400 16px/1.5 var(--f-texto);-webkit-font-smoothing:antialiased;overflow-x:hidden}
.escena{position:relative;isolation:isolate;display:grid;grid-template-columns:1fr 1fr;height:100svh;min-height:600px;overflow:hidden}
.panel{position:relative;display:grid;place-items:center}
.panel--oscuro{background:var(--negro)}
.panel--claro{background:var(--papel);color:var(--negro)}
/* Texto que se invierte al cruzar la costura negro/blanco */
.inv{position:absolute;z-index:2;color:#fff;mix-blend-mode:difference;pointer-events:none}
.barra{top:var(--margen);left:var(--margen);right:var(--margen);display:flex;justify-content:space-between;align-items:baseline;gap:16px}
.marca{font:600 clamp(18px,1.6vw,22px)/1 var(--f-display);letter-spacing:-.02em}
.mono{font:500 11.5px/1.35 var(--f-mono);letter-spacing:.07em;text-transform:uppercase}
.titular{top:calc(var(--margen) + clamp(44px,7vh,80px));left:var(--margen);font:600 clamp(38px,min(6.7vw,11.5vh),116px)/.95 var(--f-display);letter-spacing:-.045em;font-variation-settings:"opsz" 96}
.linea{display:block;overflow:hidden;padding-bottom:.12em;margin-bottom:-.12em}
.linea>span{display:block;transform:translateY(112%);animation:sube 1s var(--ease-out) forwards}
.linea:nth-child(1)>span{animation-delay:.1s}
.linea:nth-child(2)>span{animation-delay:.22s}
@keyframes sube{to{transform:none}}
.base{bottom:var(--margen);left:var(--margen);right:var(--margen);display:flex;justify-content:space-between;align-items:baseline;gap:16px}
.dato{font-variant-numeric:tabular-nums;white-space:nowrap}
.nota{position:absolute;left:var(--margen);bottom:calc(var(--margen) + 38px);max-width:min(30ch,70%)}
.panel--oscuro .nota{color:var(--gris-oscuro)}
.panel--claro .nota{color:var(--gris-claro)}
.intro{position:absolute;left:var(--margen);top:calc(var(--margen) + clamp(44px,7vh,80px) + .4em);max-width:31ch;font-size:clamp(15px,1.25vw,18px);line-height:1.5;color:var(--gris-claro);animation:aparece 1s 1s both}
.nota b{font-weight:500;color:inherit}
.panel--oscuro .nota b{color:var(--papel)}
.panel--claro .nota b{color:var(--negro)}
/* ---------- Botón magnético ---------- */
.mag{
--fill:var(--acento);--on:var(--negro);--line:var(--papel);--ink:var(--papel);
position:relative;display:flex;align-items:center;justify-content:center;
border:0;background:transparent;color:var(--ink);
font:600 clamp(20px,min(2vw,3.2vh),28px)/1 var(--f-display);letter-spacing:-.02em;
border-radius:999px;cursor:pointer;overflow:hidden;isolation:isolate;
box-shadow:inset 0 0 0 1.5px var(--line);
will-change:transform;transform:translate3d(0,0,0);
-webkit-tap-highlight-color:transparent;touch-action:manipulation;
animation:aparece .7s var(--ease-out) both;
}
.mag:focus-visible{outline:2px solid var(--acento);outline-offset:6px}
.mag__over{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
padding:inherit;border-radius:inherit;background:var(--fill);color:var(--on);
clip-path:circle(0px at 50% 50%);pointer-events:none;
}
.mag__lab{display:flex;align-items:center;gap:.6em;white-space:nowrap;pointer-events:none;
transform:translate(calc(var(--lx,0)*1px),calc(var(--ly,0)*1px))}
.mag__arr{width:1em;height:1em;flex:none;transform:rotate(calc(var(--rot,0)*1deg))}
.mag__arr path{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.mag--circulo{width:var(--d);height:var(--d)}
.mag--circulo .mag__lab{flex-direction:column;gap:.55em}
.mag--circulo .mag__arr{width:1.25em;height:1.25em}
.mag--icono{width:var(--di);height:var(--di);--line:var(--acento);--ink:var(--acento);background:var(--negro)}
.mag--icono .mag__arr{width:38%;height:38%}
.mag--pildora{
--fill:var(--negro);--on:var(--papel);--line:var(--negro);--ink:var(--negro);
height:var(--hp);padding:0 .42em 0 1.45em;font-size:clamp(20px,min(2.1vw,3.4vh),30px);
}
.mag--pildora .mag__lab{gap:1em}
.chip{display:grid;place-items:center;width:calc(var(--hp) - .84em - 1.5px);height:calc(var(--hp) - .84em - 1.5px);border-radius:50%;background:var(--acento);color:var(--negro)}
.chip .mag__arr{width:.8em;height:.8em}
.pos{position:relative;z-index:3;display:grid;place-items:center}
.pos--centro{position:absolute;z-index:3;left:50%;top:50%;translate:-50% -50%}
.campo{position:absolute;left:50%;top:50%;border-radius:50%;border:1px dashed currentColor;opacity:0;pointer-events:none;translate:-50% -50%}
.panel--oscuro .campo{color:rgba(242,241,236,.28)}
.fantasma{position:fixed;left:0;top:0;z-index:20;width:26px;height:32px;pointer-events:none;opacity:0;transition:opacity .3s}
.fantasma svg{display:block;filter:drop-shadow(0 2px 6px rgba(0,0,0,.35))}
.fantasma.on{opacity:1}
@keyframes aparece{from{opacity:0}to{opacity:1}}
.panel--oscuro .mag{animation-delay:.45s}
.pos--centro .mag{animation-delay:.6s}
.panel--claro .mag{animation-delay:.75s}
.nota,.base .dato{animation:aparece 1s .9s both}
@media (max-width:1099px){.intro{display:none}}
@media (max-width:760px){
.escena{grid-template-columns:1fr;grid-template-rows:1fr 1fr;min-height:700px}
:root{--d:clamp(140px,40vw,170px);--di:72px;--hp:76px}
.titular{font-size:clamp(34px,10.2vw,52px)}
.panel--oscuro{padding:clamp(150px,21vh,190px) 0 clamp(52px,7vh,64px)}
.panel--claro{padding:0 0 clamp(52px,8vh,72px)}
.nota{max-width:130px}
.panel--oscuro .nota{bottom:clamp(14px,2.4vh,22px)}
.panel--claro .nota{bottom:calc(var(--margen) + 30px)}
.nota .det{display:none}
.base .dato{display:none}
.barra .mono{display:none}
}
@media (max-width:360px){ .mag--pildora{font-size:19px;padding-left:1.1em} }
@media (prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
.linea>span{transform:none}
html{scroll-behavior:auto}
.fantasma{display:none}
}
```
### Script completo
El script es literal. Detalles que no se deben cambiar: el muelle hace subpasos de ≤ 8 ms; el centro de reposo de cada botón se mide una vez (y en `resize`, `ResizeObserver` y al cargar las fuentes) restando el desplazamiento actual, de modo que el bucle no lee layout; la prueba de «dentro» y la distancia al borde usan una **cápsula** (segmento + radio = mitad del alto), que sirve igual para círculos, iconos y píldoras; al cambiar el punto de origen del relleno se reescala el progreso (`p = radioActual / R_nuevo`) para que el círculo no dé un salto.
```js
(() => {
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const escena = document.getElementById("escena");
const fantasmaEl = document.getElementById("fantasma");
const datoEl = document.getElementById("dato");
/* Muelle amortiguado con subpasos: estable a cualquier dt */
class Muelle {
constructor(x, k, z) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = 2 * z * Math.sqrt(k); }
paso(dt) {
const n = Math.max(1, Math.ceil(dt / 0.008)), h = dt / n;
for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; }
}
}
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const FUERZA = 0.35, LABEL = 0.5;
const mags = [...document.querySelectorAll(".mag")].map((el, i) => ({
el, over: el.querySelector(".mag__over"), campo: el.parentElement.querySelector(".campo"),
pad0: +el.dataset.pad || 120, pad: 120,
tx: new Muelle(0, 190, 0.5), ty: new Muelle(0, 190, 0.5),
s: new Muelle(reduce ? 1 : 0.5, 260, 0.42), p: new Muelle(0, 170, 1),
campoV: 0, hover: false, focus: false, pressed: false,
ox: 0, oy: 0, R: 1, w: 1, h: 1, cx: 0, cy: 0, st: "", estilo: "",
entra: performance.now() + [450, 600, 750][i], entrado: reduce,
}));
/* Posición del reposo (sin transformaciones) en coordenadas de documento */
function medir() {
const esc = clamp(innerWidth / 1100, 0.6, 1);
for (const m of mags) {
m.pad = m.pad0 * esc;
const r = m.el.getBoundingClientRect();
m.w = m.el.offsetWidth; m.h = m.el.offsetHeight;
m.cx = r.left + r.width / 2 - m.tx.x + scrollX;
m.cy = r.top + r.height / 2 - m.ty.x + scrollY;
if (m.campo) { const d = Math.max(m.w, m.h) + m.pad * 2; m.campo.style.width = m.campo.style.height = d + "px"; }
}
}
new ResizeObserver(medir).observe(escena);
addEventListener("resize", medir);
medir();
document.fonts && document.fonts.ready.then(medir);
/* ---- Puntero real ---- */
const P = { x: -1e4, y: -1e4, ultimo: -1e9, tipo: "mouse", sueltaEn: 0 };
const mover = e => { P.x = e.clientX; P.y = e.clientY; P.tipo = e.pointerType; P.ultimo = performance.now(); P.sueltaEn = 0; };
addEventListener("pointermove", mover, { passive: true });
addEventListener("pointerdown", e => {
mover(e);
const el = e.target.closest && e.target.closest(".mag");
for (const m of mags) m.pressed = m.el === el;
}, { passive: true });
const soltar = e => {
for (const m of mags) m.pressed = false;
if (e.pointerType === "touch") P.sueltaEn = performance.now() + 420;
};
addEventListener("pointerup", soltar, { passive: true });
addEventListener("pointercancel", soltar, { passive: true });
document.documentElement.addEventListener("pointerleave", () => { P.x = P.y = -1e4; P.ultimo = performance.now(); });
for (const m of mags) {
m.el.addEventListener("click", () => { m.s.v += 4.5; });
m.el.addEventListener("focus", () => { m.focus = m.el.matches(":focus-visible"); });
m.el.addEventListener("blur", () => { m.focus = false; });
}
/* ---- Cursor fantasma: enseña la interacción cuando nadie mueve el ratón ---- */
const G = { on: false, x: 0, y: 0, cola: [], seg: null, a: -0.6, k: 0, orden: [0, 1, 2, 1] };
const eInOut = t => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
const eOut = t => 1 - Math.pow(1 - t, 3);
const eIn = t => t * t;
const dentro = (x, y) => [clamp(x, 14, innerWidth - 40), clamp(y, 14, innerHeight - 50)];
function centro(m) { return [m.cx - scrollX, m.cy - scrollY]; }
function planear() {
const m = mags[G.orden[G.k++ % G.orden.length]];
const [cx, cy] = centro(m);
const R0 = Math.max(m.w, m.h) / 2, fuera = (R0 + m.pad * 0.9) * 1.1;
const a = (G.a += 2.15), b = a + 2.6, c = a + Math.PI + 0.8;
const O = dentro(cx + Math.cos(a) * fuera, cy + Math.sin(a) * fuera * 0.8);
const I = [cx + Math.cos(b) * m.h * 0.2, cy + Math.sin(b) * m.h * 0.2];
const D = [cx + Math.cos(b + 2) * m.h * 0.24, cy + Math.sin(b + 2) * m.h * 0.24];
const X = dentro(cx + Math.cos(c) * fuera * 1.25, cy + Math.sin(c) * fuera * 1.25 * 0.8);
const dist = Math.hypot(O[0] - G.x, O[1] - G.y);
const duraViaje = clamp(dist / (innerWidth * 0.8), 0.7, 1.5) * 1000;
G.cola.push(
{ p: O, d: duraViaje, e: eInOut }, { p: I, d: 800, e: eOut },
{ p: D, d: 950, e: eInOut }, { p: X, d: 700, e: eIn }
);
}
function siguienteSeg(t0) {
if (!G.cola.length) planear();
const s = G.cola.shift();
G.seg = { x0: G.x, y0: G.y, x1: s.p[0], y1: s.p[1], t0, d: s.d, e: s.e };
}
function pasoFantasma(now) {
if (!G.seg) siguienteSeg(now);
let u = (now - G.seg.t0) / G.seg.d;
while (u >= 1) {
G.x = G.seg.x1; G.y = G.seg.y1;
siguienteSeg(G.seg.t0 + G.seg.d);
u = (now - G.seg.t0) / G.seg.d;
}
const e = G.seg.e(u);
G.x = G.seg.x0 + (G.seg.x1 - G.seg.x0) * e;
G.y = G.seg.y0 + (G.seg.y1 - G.seg.y0) * e;
}
/* ---- Bucle ---- */
let visible = true, raf = 0, previo = performance.now(), activo = 0, datoTxt = "";
const fmt = v => (v < 0 ? "−" : "+") + String(Math.round(Math.abs(v))).padStart(2, "0");
function cuadro() {
raf = 0;
const now = performance.now();
const dt = clamp((now - previo) / 1000, 0.001, 0.033); previo = now;
if (P.sueltaEn && now > P.sueltaEn) { P.x = P.y = -1e4; P.sueltaEn = 0; P.ultimo = now; }
const limite = P.tipo === "touch" ? 1800 : 4000;
const nuncaMovido = P.ultimo < 0;
const fantasma = !reduce && (nuncaMovido ? now > 1000 : now - P.ultimo > limite);
if (fantasma && !G.on) { G.on = true; G.x = innerWidth * 0.5; G.y = innerHeight + 30; G.cola.length = 0; G.seg = null; }
if (!fantasma && G.on) G.on = false;
if (fantasma) pasoFantasma(now);
fantasmaEl.classList.toggle("on", fantasma);
if (fantasma) fantasmaEl.style.transform = `translate3d(${G.x.toFixed(1)}px,${G.y.toFixed(1)}px,0)`;
const px = fantasma ? G.x : P.x, py = fantasma ? G.y : P.y;
const sx = scrollX, sy = scrollY;
mags.forEach((m, i) => {
if (!m.entrado && now >= m.entra) { m.entrado = true; m.s.t = 1; }
const rx = m.cx - sx, ry = m.cy - sy; // centro en reposo (viewport)
const mitad = m.h / 2, tramo = Math.max(0, m.w / 2 - mitad);
const dist = (x, y) => Math.hypot(x - clamp(x, -tramo, tramo), y) - mitad; // distancia a la cápsula (<0 dentro)
const dEdge = dist(px - rx, py - ry);
const dentroA = dist(px - (rx + m.tx.x), py - (ry + m.ty.x)) <= 0;
const hover = dentroA || m.focus;
/* imán */
let tx = 0, ty = 0;
if (!reduce && px > -1e3) {
const f = 1 - smooth(0.25, 1, Math.max(0, dEdge) / m.pad);
tx = (px - rx) * FUERZA * f; ty = (py - ry) * FUERZA * f;
}
m.tx.t = tx; m.ty.t = ty;
m.s.t = !m.entrado ? 0.5 : m.pressed ? 0.93 : hover ? 1.06 : 1;
/* relleno desde el punto de entrada / vaciado hacia el de salida */
if (hover !== m.hover) {
const porFoco = m.focus && !dentroA;
const lx = porFoco ? m.w / 2 : px - (rx + m.tx.x - m.w / 2);
const ly = porFoco ? m.h / 2 : py - (ry + m.ty.x - m.h / 2);
const Rn = Math.hypot(Math.max(lx, m.w - lx), Math.max(ly, m.h - ly)) + 2;
const rActual = m.p.x * m.R;
m.p.x = rActual / Rn; m.p.v = m.p.v * m.R / Rn;
m.ox = lx; m.oy = ly; m.R = Rn; m.hover = hover; m.p.t = hover ? 1 : 0;
if (reduce) { m.p.x = m.p.t; m.p.v = 0; }
}
if (hover) activo = i;
if (!reduce) { m.tx.paso(dt); m.ty.paso(dt); m.s.paso(dt); }
m.p.paso(dt);
const pp = clamp(m.p.x, 0, 1.2);
const tr = `translate3d(${m.tx.x.toFixed(2)}px,${m.ty.x.toFixed(2)}px,0) scale(${m.s.x.toFixed(4)})`;
if (tr !== m.st) { m.el.style.transform = tr; m.st = tr; }
const cp = pp < 0.002 ? "circle(0px at 50% 50%)" : `circle(${(pp * m.R).toFixed(1)}px at ${m.ox.toFixed(1)}px ${m.oy.toFixed(1)}px)`;
const est = cp + "|" + m.tx.x.toFixed(1) + m.ty.x.toFixed(1) + pp.toFixed(3);
if (est !== m.estilo) {
m.estilo = est;
m.over.style.clipPath = cp;
m.el.style.setProperty("--lx", (m.tx.x * LABEL).toFixed(2));
m.el.style.setProperty("--ly", (m.ty.x * LABEL).toFixed(2));
m.el.style.setProperty("--rot", (-45 * clamp(pp, 0, 1)).toFixed(1));
}
if (m.campo) {
const cerca = px > -1e3 && dEdge < m.pad;
m.campoV += ((hover ? 1 : cerca ? 0.55 : 0) - m.campoV) * (1 - Math.exp(-dt * 9));
m.campo.style.opacity = m.campoV.toFixed(3);
m.campo.style.transform = `scale(${(0.94 + 0.06 * m.campoV).toFixed(3)})`;
}
});
const a = mags[activo];
const txt = `imán x ${fmt(a.tx.x)} y ${fmt(a.ty.x)} · relleno ${String(Math.round(clamp(a.p.x, 0, 1) * 100)).padStart(3, "0")} %`;
if (txt !== datoTxt) { datoTxt = txt; datoEl.textContent = txt; }
if (visible && !document.hidden) raf = requestAnimationFrame(cuadro);
}
const arrancar = () => { if (!raf && visible && !document.hidden) { previo = performance.now(); raf = requestAnimationFrame(cuadro); } };
new IntersectionObserver(es => { visible = es[0].isIntersecting; if (visible) arrancar(); }).observe(escena);
document.addEventListener("visibilitychange", arrancar);
arrancar();
})();
```
## Interacción
- **Ratón / lápiz**: el puntero se escucha en `window` (`pointermove`); no hay listeners por botón. Un botón está «en hover» si el puntero cae dentro de su cápsula real (con su desplazamiento actual). El imán empieza a `pad` píxeles del borde (círculo 120, icono 110, píldora 130, escalados por `clamp(ancho / 1100, .6, 1)`) y llega a su fuerza completa a un 25 % de esa distancia. Pulsar reduce la escala a 0,93; soltar la devuelve; el clic da un empujón al muelle.
- **Táctil**: un toque llena el botón desde el punto tocado y, 420 ms tras soltar, se vacía. Si no hay toque durante 1,8 s, entra el cursor fantasma y recorre los botones.
- **Sin puntero**: al cargar, a 1 s entra el fantasma. Si el ratón se mueve, el fantasma se oculta al instante; si se queda quieto 4 s, vuelve. Al salir el ratón de la ventana (`pointerleave`) se suelta el puntero.
- **Teclado**: Tab enfoca el botón (`:focus-visible` con contorno de acento de 2 px y separación de 6 px) y lo rellena desde el centro; salir del foco lo vacía. Intro y espacio activan el clic nativo del `<button>`.
- Los botones del demo son `<button type="button">`. En producción, el principal debería ser un `<a href>` con la misma clase y atributos.
## Adaptación a móvil y tablet
- **≥ 1100 px**: composición completa, con el párrafo `.intro` en la mitad papel.
- **761–1099 px**: igual, sin `.intro`.
- **≤ 760 px**: una columna con dos filas iguales (negro arriba, papel abajo; el botón-icono queda en la costura horizontal). El titular baja a `clamp(34px,10.2vw,52px)`. Círculo `clamp(140px,40vw,170px)`, icono 72 px, píldora 76 px de alto. Las notas pierden su segunda línea (`.det`), desaparece la lectura en vivo y el texto superior derecho. Alturas mínimas: 700 px.
- **≤ 360 px**: la píldora baja a 19 px de texto y su relleno izquierdo a 1,1 em.
- Nunca hay scroll horizontal; el `body` lleva `overflow-x: hidden`.
## Accesibilidad y movimiento reducido
- `<html lang="es">`; cada mitad es un `<section>` con `aria-label`; el icono lleva `aria-label="Siguiente"`; la capa de relleno, la lectura, el anillo y el fantasma son `aria-hidden`.
- Contraste AA: texto papel (#f2f1ec) sobre negro; texto negro sobre papel; texto secundario #a3a39d sobre negro y #5f5f5a sobre papel; etiquetas en negro sobre acento en el relleno.
- `prefers-reduced-motion: reduce`: sin animaciones CSS, titular en su sitio, sin imán, sin escala, sin fantasma; el relleno sigue existiendo pero es **instantáneo** (el progreso salta a 0 o 1) y no se anima nada más. La página queda estática y legible.
## Rendimiento
- Un único bucle `requestAnimationFrame`, sin lecturas de layout dentro (posiciones medidas aparte). Solo se escriben `transform`, `clip-path` y tres variables CSS, y solo cuando cambian.
- El bucle se detiene cuando la pieza no está en pantalla (`IntersectionObserver`) o la pestaña está oculta, y se reanuda al volver. Si el observador aún no ha informado, se asume visible.
- Sin WebGL, sin canvas, sin imágenes: nada que haga fallback. Peso total del HTML ≈ 20 KB.
- Funciona dentro de un iframe: no usa `window.top`, almacenamiento ni navegación.
## Personalización
- **Marca**: `Polo` (arriba a la izquierda y en el pie) y `Northbridge` (pie).
- **Titular**: `Un botón que` y `viene a buscarte.`; mantén dos líneas cortas.
- **Botones**: `Hablemos` y `Reservar cita`. Si el texto de la píldora es largo, aumenta su padding derecho en vez de reducir la fuente.
- **Color vivo**: `#ff4b14` (naranja `#ff4b14` por defecto; prueba rojo `#ff2d2d`, azul eléctrico `#2b4bff`, verde ácido `#c8ff2e` con texto negro o amarillo `#ffd400`). Cambia `--on` del botón si el acento es oscuro.
- **Negro y papel**: `#0b0b0c` y `#f2f1ec`. Para invertir las mitades, intercambia las clases `panel--oscuro` y `panel--claro` y los valores `--fill`, `--on`, `--line` e `--ink` de cada botón.
- **Fuerza del imán**: constante `FUERZA` (0,35). Para un efecto más juguetón sube a 0,5; para algo discreto, 0,2. `LABEL` (0,5) controla el paralaje de la etiqueta.
- **Muelles**: `new Muelle(0, 190, 0.5)` es rigidez y razón de amortiguamiento (menos = más rebote).
## Comprobación final
1. Sin errores en consola; las fuentes cargan y el titular se ve en Bricolage Grotesque.
2. A 1440×900 hay dos mitades (negro | papel), titular de dos líneas arriba a la izquierda, círculo en el centro de la mitad negra, píldora en el centro de la mitad papel y el icono exactamente sobre la costura.
3. El texto del titular que cae sobre la mitad papel es negro; sobre la negra, blanco.
4. Al entrar con el ratón por la izquierda del círculo, el relleno nace en el lado izquierdo; al salir por arriba, se encoge hacia arriba.
5. El botón se desplaza hacia el cursor antes de tocarlo y rebota un poco al asentarse; la etiqueta se mueve más que el botón.
6. El texto de la capa de relleno coincide pixel a pixel con el de la capa base (sin saltos al cruzar el borde).
7. La flecha gira de → a ↗ mientras se rellena.
8. Con el ratón quieto 4 s (o sin ratón), aparece el cursor fantasma y visita círculo, icono y píldora.
9. Tab enfoca cada botón con contorno visible y lo rellena desde el centro.
10. En 390×844 las dos mitades se apilan, el icono queda en la costura y nada se solapa ni hay scroll horizontal.
11. Con movimiento reducido no hay imán, escala ni fantasma, y el relleno es instantáneo.
12. Cambiar `#ff4b14` recolorea relleno, contorno del icono, chip y anillo sin tocar nada más.Prueba en frío
Aún sin publicar para esta pieza
En la prueba en frío pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos lo que genera con la demo. Cuando esté hecha, aquí verás la captura, el veredicto y la fecha.
Paso a paso
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.
Puedes
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 01-10-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