Nº 087 of 520
Variable font proximity headline
A 148px Fraunces headline where every letter's weight (300–900) follows cursor distance within a 260px radius, easing back on leave; keyboard and reduced motion get a static 800.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Variable font proximity headline" (piece variable-font-proximity) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 087 · "Variable font proximity headline" · www.designlounge.live -->
# Variable font proximity headline
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A type-foundry specimen hero on a cream page. The headline "Weight follows your hand" is set in Fraunces Variable at 148px, weight 300. As the pointer moves across it, each letter's `wght` axis is driven by its distance to the cursor: the nearest glyph reaches 900 and turns rust-red, letters within a 260px radius blend between, everything else stays at 300. On leave, the letters ease back to 300 over 520ms. Keyboard focus and `prefers-reduced-motion` show a static heavy state (800) so nobody misses the point. The detail worth copying: weight is written per-letter via a custom property (`--w`) and rendered by `font-variation-settings`, with a CSS transition doing the smoothing, so the JS only sets numbers.
## Reference behaviour
1. Initial state: 64px header (brand, 4 nav links, dark pill CTA "Trial fonts"), a kicker line "Fraunces Variable · 100–900 · optical size 9–144", the headline at weight 300, a 420px paragraph, three stats on the right, and a 52px footer with a status readout "Weight 300 — resting" beside a 6px rust dot.
2. Pointer enters the `<h1>`: letter centres are measured once (`getBoundingClientRect`), the footer dot scales to 1.6 over 160ms.
3. Pointer moves: on each `requestAnimationFrame`, for every letter compute `d` = distance from cursor to the letter's centre, `t = smoothstep(1 − d/260)`, then `wght = 300 + 600·t` and `SOFT = 30 + 70·t`. Letters with `t > 0.92` get class `.hot` (colour `--accent`). Each letter's `font-variation-settings` transitions over 180ms so the weight glides rather than snaps.
4. The footer readout updates to the heaviest weight in the frame: "Weight 900 — peak" or "Weight 640 — blending".
5. Pointer leaves: every letter gets `.leave` (transition 520ms expo-out), `--w` resets to 300, `.hot` is removed, readout returns to "Weight 300 — resting", dot scales back.
6. Tab to the headline (`tabindex="0"`): a 2px rust focus ring at 4px offset appears and all letters jump to weight 800 / SOFT 60 with no transition; readout says "Weight 800 — keyboard".
7. With `prefers-reduced-motion: reduce`, the body gets `.static`: the headline sits permanently at weight 800, the pointer handlers are never attached, readout says "Weight 800 — static (reduced motion)".
8. Hovering nav links darkens them to `--ink`; hovering the CTA fills it with `--accent`.
## Structure
```
1280 × 800
┌────────────────────────────────────────────────────────────────────────┐
│ header 64 ◎ Loam Type Foundry Families Licensing In use Journal [Trial fonts] │
├────────────────────────────────────────────────────────────────────────┤
│ 48px gutter │
│ — FRAUNCES VARIABLE · 100–900 · OPTICAL SIZE 9–144 (kicker 12px) │
│ │
│ Weight follows ← h1 148px / 0.92, max-width 1100│
│ your hand │
│ │
│ paragraph 420px ········· 9 axes 2,148 € 180 (meta, right) │
├────────────────────────────────────────────────────────────────────────┤
│ footer 52 ● Weight 300 — resting Loam Type Foundry · Specimen 04 │
└────────────────────────────────────────────────────────────────────────┘
```
… 161 more lines. Copy or download for the full brief. Loading the source…Brief
201 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A type-foundry specimen hero on a cream page. The headline “Weight follows your hand” is set in Fraunces Variable at 148px, weight 300. As the pointer moves across it, each letter’s wght axis is driven by its distance to the cursor: the nearest glyph reaches 900 and turns rust-red, letters within a 260px radius blend between, everything else stays at 300. On leave, the letters ease back to 300 over 520ms. Keyboard focus and prefers-reduced-motion show a static heavy state (800) so nobody misses the point. The detail worth copying: weight is written per-letter via a custom property (--w) and rendered by font-variation-settings, with a CSS transition doing the smoothing, so the JS only sets numbers.
Reference behaviour
- Initial state: 64px header (brand, 4 nav links, dark pill CTA “Trial fonts”), a kicker line “Fraunces Variable · 100–900 · optical size 9–144”, the headline at weight 300, a 420px paragraph, three stats on the right, and a 52px footer with a status readout “Weight 300 — resting” beside a 6px rust dot.
- Pointer enters the
<h1>: letter centres are measured once (getBoundingClientRect), the footer dot scales to 1.6 over 160ms. - Pointer moves: on each
requestAnimationFrame, for every letter computed= distance from cursor to the letter’s centre,t = smoothstep(1 − d/260), thenwght = 300 + 600·tandSOFT = 30 + 70·t. Letters witht > 0.92get class.hot(colour--accent). Each letter’sfont-variation-settingstransitions over 180ms so the weight glides rather than snaps. - The footer readout updates to the heaviest weight in the frame: “Weight 900 — peak” or “Weight 640 — blending”.
- Pointer leaves: every letter gets
.leave(transition 520ms expo-out),--wresets to 300,.hotis removed, readout returns to “Weight 300 — resting”, dot scales back. - Tab to the headline (
tabindex="0"): a 2px rust focus ring at 4px offset appears and all letters jump to weight 800 / SOFT 60 with no transition; readout says “Weight 800 — keyboard”. - With
prefers-reduced-motion: reduce, the body gets.static: the headline sits permanently at weight 800, the pointer handlers are never attached, readout says “Weight 800 — static (reduced motion)”. - Hovering nav links darkens them to
--ink; hovering the CTA fills it with--accent.
Structure
1280 × 800
┌────────────────────────────────────────────────────────────────────────┐
│ header 64 ◎ Loam Type Foundry Families Licensing In use Journal [Trial fonts] │
├────────────────────────────────────────────────────────────────────────┤
│ 48px gutter │
│ — FRAUNCES VARIABLE · 100–900 · OPTICAL SIZE 9–144 (kicker 12px) │
│ │
│ Weight follows ← h1 148px / 0.92, max-width 1100│
│ your hand │
│ │
│ paragraph 420px ········· 9 axes 2,148 € 180 (meta, right) │
├────────────────────────────────────────────────────────────────────────┤
│ footer 52 ● Weight 300 — resting Loam Type Foundry · Specimen 04 │
└────────────────────────────────────────────────────────────────────────┘
<header>— flex row,border-bottom: 1px solid --line..brand(22px inline SVG + name),<nav aria-label="Primary">with four<a>,<button class="cta">.<main>— flex column,justify-content: center, padding0 48px 24px.<p class="kicker">with a 28px hairline drawn by::before.<h1 id="hl" tabindex="0" aria-label="Weight follows your hand">— JS splits the text into<span class="l">per letter and<span class="sp">per space, allaria-hidden="true"..sub— 2-column grid420px 1fr, gap 48px, align end: paragraph left,.metastats right.
<footer>—.status(.dot+#readlive text) and a right-aligned caption.
Tokens
:root {
/* colour — cream paper, warm near-black ink, one rust accent */
--bg: #f4efe4; /* page */
--bg-2: #ede6d6; /* reserved for hover surfaces */
--ink: #1c1a16; /* headline, primary text */
--ink-2: #6b655a; /* paragraph, nav */
--ink-3: #9a9385; /* kicker, meta labels, footer */
--line: #d9d1c0; /* hairlines */
--accent: #b4472b; /* hot letters, focus ring, CTA hover, dot */
/* type */
--serif: "Fraunces", Georgia, serif; /* axes: opsz 9..144, wght 100..900, SOFT 0..100 */
--sans: "Instrument Sans", system-ui, sans-serif;
/* proximity model */
--w-min: 300; /* resting weight */
--w-max: 900; /* weight under the cursor */
--radius: 260px; /* falloff radius */
/* layout */
--header-h: 64px;
--footer-h: 52px;
--gutter: 48px;
--measure: 420px; /* paragraph width */
--r-focus: 8px; /* headline focus-ring radius */
/* motion */
--t-micro: 160ms; /* colour, dot */
--t-weight: 180ms; /* per-letter weight glide while hovering */
--t-leave: 520ms; /* ease back to rest */
--ease: cubic-bezier(.2, .7, .2, 1);
--ease-out: cubic-bezier(.16, 1, .3, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case | Extra axes |
|---|---|---|---|---|---|---|---|
| Headline | Fraunces | 148px | 300 → 900 var | 0.92 | −0.035em | sentence | opsz 144, SOFT 30 → 100 |
| Headline (focus / reduced) | Fraunces | 148px | 800 | 0.92 | −0.035em | sentence | SOFT 60 |
| Kicker | Instrument Sans | 12px | 400 | 1 | +0.14em | UPPERCASE | — |
| Paragraph | Instrument Sans | 17px | 400 | 1.5 | 0 | sentence | — |
| Nav / body | Instrument Sans | 14px | 400 | 1.5 | 0 | sentence | — |
| Brand | Instrument Sans | 15px | 500 | 1 | −0.01em | sentence | — |
| CTA | Instrument Sans | 13px | 500 | 1 | 0 | sentence | — |
| Stat value | Fraunces | 22px | 500 | 1.1 | −0.01em | numerals | opsz 24 |
| Stat label / footer | Instrument Sans | 13px / 12px | 400 | 1.5 | 0 | sentence | — |
Load Fraunces with the axis ranges in the URL, otherwise the browser gets static instances and nothing animates:
family=Fraunces:opsz,wght,SOFT@9..144,100..900,0..100.
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Notes |
|---|---|---|---|---|---|---|
.l (letter) | pointermove | font-variation-settings | wght 300 → up to 900, SOFT 30 → 100 | 180ms | --ease | value set per rAF; CSS transition smooths |
.l.hot | t > 0.92 | color | --ink → --accent | 160ms | --ease | at most 2–3 letters at once |
.l.leave | pointerleave | font-variation-settings | current → wght 300, SOFT 30 | 520ms | --ease-out | class swaps transition duration only |
.dot | pointerenter/leave | transform | scale 1 → 1.6 | 160ms | --ease | footer status |
h1:focus-visible .l | keyboard focus | font-variation-settings | any → wght 800, SOFT 60 | 0 | — | deliberate snap |
Falloff curve: t = clamp(1 − d / 260, 0, 1), then smoothstep t² (3 − 2t). Do not use linear falloff; the smoothstep keeps the middle letters from flickering.
Reduced motion: no pointer listeners are attached; .static .l is fixed at wght 800 with transition: none.
States
- Rest: all letters wght 300, colour
--ink; readout “Weight 300 — resting”. - Hover (headline): per-letter weights; nearest letters
.hotin--accent; footer dot scaled 1.6. - Leave: letters glide back over 520ms;
.hotremoved immediately. - Focus-visible (headline):
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent), radius 8px; letters snap to 800. - Hover (nav link): colour
--ink-2→--ink, no underline. - Hover (CTA): background
--ink→--accent. - Reduced motion:
.staticon body; letters 800 permanently; readout says so.
Accessibility
- The
<h1>keeps its full text inaria-label; the generated letter spans arearia-hidden="true"so screen readers hear one phrase, not 22 characters. tabindex="0"on the headline gives keyboard users a way to see the heavy state; the focus ring is 2px--accentwith a 2px cream gap.- Focus order: nav links → CTA → headline. No focus traps; the footer readout is plain text (add
aria-live="polite"only if the product wants the weight announced; the demo does not, to avoid chatter on every frame). - Contrast:
--inkon--bg15.2:1;--ink-2on--bg5.6:1;--ink-3(9a9385) on--bgis 3.1:1 and is used only for 12–13px uppercase meta, never body copy;--accenton--bg5.4:1. user-select: noneon the headline so dragging across it does not highlight;cursor: default.- Hit targets: nav links have 6px vertical padding (26px tall) — acceptable for web; CTA is 35px tall.
Responsive rules
- ≥ 1280: as specified; headline 148px,
.subtwo columns. - 1024–1279: headline 148px still fits (“Weight follows” ≈ 1000px); keep two columns.
- 768–1023 (
max-width: 1100pxin the demo): headline 112px;.subbecomes one column, meta left-aligned; radius stays 260px in CSS pixels. - < 640: headline 72px, radius 160px, header nav hidden behind the CTA; on touch devices there is no hover, so bind
touchmove/pointermovewithpointer-typetouch too, and add a “Press and drag” hint under the kicker.
Acceptance checklist
- Fraunces is loaded with
opsz,wght,SOFT@9..144,100..900,0..100in the Google Fonts URL. - Headline is 148px, line-height 0.92, letter-spacing −0.035em, resting weight 300.
- Each letter is its own
<span class="l">; spaces are.spspans of width 0.26em; all spans arearia-hidden. - Moving the cursor across the headline drives weight from 300 to exactly 900 on the nearest letter, with a 260px smoothstep falloff.
- Letters within t > 0.92 turn
#b4472b. - Weight changes are smoothed by a 180ms
font-variation-settingstransition while hovering. - On pointer leave all letters return to 300 over 520ms with
cubic-bezier(.16,1,.3,1). - Letter positions are measured on
pointerenterandresize, not on every move. - Focusing the headline with Tab shows a rust ring and snaps every letter to weight 800.
- With
prefers-reduced-motion: reduceno pointer listeners run and the headline is static at 800. - Footer readout shows the current peak weight and the dot scales 1.6 while hovering.
- No console errors; only one rAF is queued at a time.
Implementation notes
Let CSS do the tween. JS writes a number to a custom property; the transition on font-variation-settings interpolates it. Fraunces needs opsz pinned or the browser will auto-pick it per size:
h1 .l {
display: inline-block;
font-variation-settings: "opsz" 144, "wght" var(--w, 300), "SOFT" var(--s, 30);
transition: font-variation-settings 180ms cubic-bezier(.2,.7,.2,1), color 160ms;
}
h1 .l.leave { transition-duration: 520ms; transition-timing-function: cubic-bezier(.16,1,.3,1); }
Falloff per frame, one rAF at a time, using cached centres:
function paint() {
raf = 0; if (!last) return;
letters.forEach((l, i) => {
const dx = rects[i].x - last.x, dy = rects[i].y - last.y;
let t = Math.max(0, 1 - Math.hypot(dx, dy) / 260);
t = t * t * (3 - 2 * t); // smoothstep
l.style.setProperty('--w', Math.round(300 + 600 * t));
l.style.setProperty('--s', Math.round(30 + 70 * t));
l.classList.toggle('hot', t > 0.92);
});
}
h1.addEventListener('pointermove', e => { last = { x: e.clientX, y: e.clientY }; if (!raf) raf = requestAnimationFrame(paint); });
Split without losing the accessible name:
const text = h1.textContent; h1.textContent = ''; h1.setAttribute('aria-label', text);
for (const ch of text) { const s = document.createElement('span');
s.className = ch === ' ' ? 'sp' : 'l'; s.textContent = ch; s.setAttribute('aria-hidden', 'true'); h1.appendChild(s); }
Common mistakes: animating font-weight instead of font-variation-settings (no interpolation on many engines and it triggers font fallback swaps); forgetting display: inline-block on the spans (transforms and measurement break); measuring getBoundingClientRect on every move (layout thrash with 22 letters × 60fps); loading Fraunces without the axis ranges.
Details
- Palette
- Type
- Fraunces · Instrument Sans
- Motion
- Rich motion
- Build
- One session
- Tags
Studied
Susan AcharyaDraw one scene and let it move a little. The words stay calm on top.
All sourcesOn the shelves