Variable-font Weight Wave

A single display word split into characters whose font weight (300 → 900) and SOFT axis follow the pointer with a Gaussian falloff; leaving sends a left-to-right ripple back to rest, and touch devices get an automatic looping wave.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1231 words of exact values — rewritten live as you tune the Playground.

prompt.md · Variable-font Weight Wave
### EFFECT: Variable-font Weight Wave
**What it does (visual):** A full-height, centred stage with a mono eyebrow ("Text · Variable font · wght 100–900 · SOFT 0–100"), one enormous serif word — "Resonance" — set light (weight 300) in a variable serif, and a mono readout "wght 300" below it. The cursor becomes a crosshair over the stage. As the pointer moves horizontally across the word, the letters nearest the cursor swell to weight 900 and — when the SOFT axis is driven (true) — their terminals soften (the font's SOFT axis goes to 100), with neighbours swelling less in a smooth bell curve about 1.1 characters wide: the letter under the cursor is bold, the ones either side medium, the rest still light. Each letter eases toward its new weight over 0.4s, so the bulge lags the cursor like liquid. The readout shows the heaviest current weight. When the pointer leaves the stage, a ripple runs left → right through the word: each letter in turn briefly swells to 900 (0.28s) and relaxes back to 300 (0.6s), 0.03s after the previous one, and the readout returns to 300. On touch devices the same ripple plays automatically on a loop every ~2s.

**Tech stack:**
- GSAP 3.13+ with SplitText (free since 3.13). No ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- A variable font with a wide weight axis. The demo uses Fraunces (axes `wght` 100–900, `opsz` 9–144, `SOFT` 0–100, `WONK` 0/1); with another font drop the `SOFT` term and drive `wght` only.

**HTML structure:**
```html
<section class="vfw">
  <div class="vfw__inner">
    <p class="vfw__eyebrow">Text · Variable font<span class="vfw__axes"> · wght 100–900 · SOFT 0–100</span></p>
    <h1 class="vfw__word">Resonance</h1>
    <div class="vfw__meta">
      <span class="vfw__read">wght <b class="vfw__read-w">300</b></span>
      <span class="vfw__hint">Move across the word</span>
    </div>
  </div>
</section>
```
SplitText wraps each character in `<div class="vfw__char">` at runtime.

**CSS requirements:**
- `.vfw`: `min-height: 100vh; display: grid; place-items: center; padding: 8vw 4vw; background: var(--bg, #0a0a0a); color: var(--text, #ededed); cursor: crosshair; touch-action: pan-y;`
- `.vfw__inner`: `width: 100%; max-width: 1400px; text-align: center;` (centring makes the width-breathing symmetric).
- `.vfw__word`: `font-family: var(--font-display); font-size: clamp(3rem, 14vw, 13rem); line-height: 1; letter-spacing: -0.03em; white-space: nowrap; --wght: 300; --soft: 0; font-variation-settings: 'wght' var(--wght), 'SOFT' var(--soft), 'opsz' 144;`
- `.vfw__char`: `display: inline-block; --wght: 300; --soft: 0; font-variation-settings: 'wght' var(--wght), 'SOFT' var(--soft), 'opsz' 144; will-change: font-variation-settings;` — JS writes `--wght` and `--soft` per character; do not set `font-weight` (it is overridden by `font-variation-settings`).
- `.vfw__eyebrow`, `.vfw__meta`: mono 11px uppercase `letter-spacing: .12–.14em; color: var(--muted);` meta is `display: flex; justify-content: center; gap: 3rem; margin-top: 3rem;` readout number `font-variant-numeric: tabular-nums; min-width: 3ch; color: var(--text)`.
- `@media (hover: none) { .vfw { cursor: default; } .vfw__hint { display: none; } }`; under 480px hide `.vfw__axes` so the eyebrow stays on one line.

**Animation spec (exact values):**
Constants: `REST = 300`, `PEAK = 900`, `SIGMA_CHARS = 1.1`, `FOLLOW = 0.4`, `LEAVE_STAGGER = 0.03`, `SOFT = true` (drive the SOFT axis; when false `s` stays 0).
1. After `document.fonts.ready`: `split = SplitText.create('.vfw__word', { type: 'chars', charsClass: 'vfw__char', aria: 'auto' })`; `chars = split.chars`.
2. State: one proxy per character `states[i] = { w: 300, s: 0 }`; `apply(i)` writes `chars[i].style.setProperty('--wght', states[i].w)` and `setProperty('--soft', states[i].s)`.
3. Geometry (on load and on resize, debounced 100ms): `centers[i] = rect.left + rect.width / 2`; `sigma = 1.1 × average character width` (px).
4. Setters: `wTo[i] = gsap.quickTo(states[i], 'w', { duration: 0.4, ease: 'power2.out', onUpdate: () => apply(i) })` and the same for `'s'` → `sTo[i]`.
5. `pointermove` on `.vfw` (hover-capable devices only):
   ```js
   const d = e.clientX - centers[i];
   const g = Math.exp(-(d * d) / (2 * sigma * sigma));   // Gaussian, 1 at the pointer → 0 far away
   wTo[i](300 + (900 - 300) * g, states[i].w);   // target, start-from-current
   sTo[i](SOFT ? 100 * g : 0, states[i].s);                 // SOFT = true
   readout.textContent = Math.round(max weight);
   ```
   Also `leaveTl?.kill()` so a re-entry interrupts the ripple.
6. `pointerleave`: pause every quickTo tween, then
   ```js
   leaveTl = gsap.timeline({ onUpdate: applyAll })   // applyAll also writes the current peak weight to the readout
     .to(states, { w: 900, s: SOFT ? 100 : 0, duration: 0.28, ease: 'sine.in', stagger: 0.03 }, 0)
     .to(states, { w: 300, s: 0, duration: 0.6, ease: 'power2.out', stagger: 0.03 }, 0.28);
   ```
   — target: every state proxy, left → right; bump to 900 then settle to 300; the second tween is positioned at 0.28 so each character's settle begins exactly when its bump ends.
7. Touch branch (`gsap.matchMedia('(hover: none), (pointer: coarse)')`): `autoTl = gsap.timeline({ repeat: -1, repeatDelay: 0.8, onUpdate: applyAll }).to(states, { w: 900, s: SOFT ? 100 : 0, duration: 0.45, ease: 'sine.inOut', stagger: 0.06 }, 0).to(states, { w: 300, s: 0, duration: 0.7, ease: 'power2.out', stagger: 0.06 }, 0.45)`.

**Interaction:** Pointer position (x only) drives the weight field; the response lags 0.4s (`power2.out`). Leaving the stage triggers the left-to-right ripple; re-entering kills it and resumes following. Clicking does nothing. The readout updates on every move and resets on the ripple's completion.

**Responsive behavior:** `gsap.matchMedia()` with `'(hover: hover) and (pointer: fine)'` attaches the pointer listeners; `'(hover: none), (pointer: coarse)'` runs the automatic loop instead and hides the hint. Word size is `clamp(3rem, 14vw, 13rem)` with `white-space: nowrap` — "Resonance" (9 characters) fits a 375px viewport at ~52px. Character centres are re-measured on resize.

**Accessibility:** `aria: 'auto'` gives the heading an `aria-label` with the whole word and hides the character pieces from assistive tech. With `prefers-reduced-motion: reduce`: split once, then set a frozen wave — `w = 300 + (900 − 300)·sin(i/(n−1)·π)`, `s = (SOFT ? 100 : 0)·sin(…)` — with no listeners or loops, so the page still shows the idea statically. The stage has no interactive controls, so no focus management is needed.

**Performance rules:** Nothing animates per frame unless the pointer moves or a ripple is running; each move retargets 2 × 9 existing quickTo tweens (no tween creation). Writing `font-variation-settings` re-shapes text (layout + paint) — keep this to a single short word, never a paragraph. `will-change: font-variation-settings` on the characters. Everything lives in `gsap.context()`; `ctx.revert()` on unmount reverts the split and kills tweens; remove the resize listener in the same cleanup.

**Customization knobs:**
- `REST` / `PEAK` (300 / 900) — e.g. 200 / 700 for a subtler swell.
- `SIGMA_CHARS` (1.1) — width of the bulge in characters; 0.6 is a sharp point, 2.5 a broad wave.
- `FOLLOW` (0.4) — lag; 0.15 feels snappy, 0.8 feels syrupy.
- `LEAVE_STAGGER` (0.03) and the bump/settle durations (0.28 / 0.6).
- `SOFT` (true) — drive the Fraunces SOFT axis alongside weight; turn off for fonts without it.
- Drive other axes the same way (`'opsz'`, `'slnt'`, or Fraunces' `'WONK'` as a 0/1 toggle at g > 0.5).

**Acceptance criteria:**
- [ ] At rest every character is weight 300; the readout says 300.
- [ ] Moving the pointer swells the nearest letter to ~900 with a smooth bell-shaped falloff over neighbours; the bulge visibly lags the cursor by a few hundred ms.
- [ ] Leaving the stage produces a clean left-to-right ripple (bump then settle, 0.03s cascade) ending at 300 everywhere, with no pop at the hand-off.
- [ ] On touch devices the ripple loops automatically; the hint is hidden.
- [ ] Reduced-motion users see a static weight wave across the word.
- [ ] Resizing keeps the bulge aligned with the cursor; no console errors.

Source

Full source code

<section class="vfw">
  <div class="vfw__inner">
    <p class="vfw__eyebrow">Text · Variable font<span class="vfw__axes"> · wght 100–900 · SOFT 0–100</span></p>
    <h1 class="vfw__word">Resonance</h1>
    <div class="vfw__meta">
      <span class="vfw__read">wght <b class="vfw__read-w">300</b></span>
      <span class="vfw__hint">Move across the word</span>
    </div>
  </div>
</section>