VariableProximity
Cursor-reactive variable-font typography.
Live demo
01Move your cursor over each phrase below. Every section drives a different variable-font
axis (or combination), with different radius and falloffCurve settings. Best viewed in Chrome, Edge, or Safari with Inter Variable, Roboto Flex, or San
Francisco installed.
Weight axis Β· wght 400β800 Β· radius 120 Β· quadratic
The default β letters bloom in weight where the cursor lingers. Quadratic falloff gives a smooth, non-spiky bulge.
Width axis Β· wdth 75β125 Β· radius 160 Β· gaussian
Stretches letters horizontally as the cursor approaches. The Gaussian falloff has a softer bell, so the stretch tapers gently. Wide radius to keep things readable.
Slant axis Β· slnt 0β-12 Β· radius 140 Β· linear
Italicises letters near the cursor. Linear falloff means a sharp, triangular response β letters lean noticeably right at the centre and snap upright at the radius edge.
Multi-axis Β· wght + wdth + opsz Β· radius 150
Three axes drive simultaneously β weight blooms, width stretches, and optical-size shifts to display-grade glyph shapes. The compound effect feels like the type is alive under the cursor.
Radius comparison Β· 60 vs 140 vs 220 px
Same axes, three different radii. The 60px sample is hyper-local (almost a single letter), 140 is the sweet spot, 220 covers the whole phrase at once.
Long form Β· wght 400β700 Β· gentle radius 130
On longer copy, a moderate weight range and a small radius keep the effect from overpowering the read. Hover to follow the cursor, line by line.
Implementation
02<script lang="ts">
import VariableProximity from '$lib/components/VariableProximity.svelte';
</script>
β
<VariableProximity text="Drift the focus close" radius={140} falloffCurve="gaussian" />VariableProximity measures each glyph's position, computes its distance from the cursor every frame inside a single rAF callback, and writes one font-variation-settings string per letter. CSS owns the easing via a transition on font-variation-settings, so the effect costs nothing when the cursor isn't moving and degrades gracefully where variable-font support is missing.
Logic explainer
03What Does It Do? (Plain English)
VariableProximity splits a phrase into per-letter spans, then drives each letter's variable-font axes (weight, width, slant, optical size) by how close the cursor is. Letters near the pointer swell β heavier weight, wider letterforms, optional slant. Slant also gets a skewX(...) wrapper transform so the demo remains visible on system fonts that expose variable weight/width but not a true slnt axis. Letters far away rest at their base. There is no timer; the effect is purely cursor-reactive. Move away and every letter relaxes back to base.
Think of it as a magnifying glass for typography. Where your finger lands, the glyphs respond.
How It Works (Pseudo-Code)
state:
letterEls = Map<index, HTMLElement>
cursorPos = { x, y } | null
prefersReduced = matchMedia query
supportsVariable = CSS.supports('font-variation-settings: "wght" 400')
rafHandle = null
derive:
letters = Array.from(text).map(c => { char, isSpace })
baseSettings = buildVariationSettings(axes, 0) // proximity = 0 β base values
helpers (pure, exported):
distance(p1, p2) β Euclidean
falloff(d, radius, curve) β 0..1 weight
'linear': 1 - d/radius
'quadratic': (1 - d/radius)^2
'gaussian': exp(-((1 - (1 - d/radius)) * 2)^2)
axisInterpolate(t, base, peak) β base + (peak - base) * t
buildVariationSettings(axes, proximity) β '"wght" 712.50, "wdth" 113.20'
events:
on pointermove(e):
if prefersReduced or !supportsVariable: bail
cursorPos = { e.clientX, e.clientY }
scheduleApply() // rAF-throttled
on pointerleave / blur:
cursorPos = null
scheduleApply() // resets every letter to base
on focus (keyboard parity):
cursorPos = wrapper centre
scheduleApply()
scheduleApply():
if rafHandle: bail (one rAF outstanding at a time)
rafHandle = requestAnimationFrame(applyAxesFromCursor)
applyAxesFromCursor():
rafHandle = null
if cursorPos null:
for el of letterEls: el.style.fontVariationSettings = baseSettings
return
wRect = wrapper.getBoundingClientRect()
for el of letterEls:
lRect = el.getBoundingClientRect()
centre = letter midpoint relative to wrapper
d = distance(cursor relative to wrapper, centre)
prox = falloff(d, radius, falloffCurve)
el.style.fontVariationSettings = buildVariationSettings(axes, prox)The Core Concept: Distance β Falloff β Axis Interpolation
The component's "shape" comes from one composition: every pointermove writes a fresh font-variation-settings per letter, writes a matching skewX(...) transform when a slnt axis is configured, and CSS transitions interpolate to those new values. The chain has three stages.
Stage 1 β distance. Euclidean distance between cursor and letter centre, in pixels relative to the wrapper:
d = β((cursor.x - letter.cx)Β² + (cursor.y - letter.cy)Β²)Stage 2 β falloff curve. Map d into a 0..1 proximity weight:
t = 1 - d/radius (clamped: 1 at d=0, 0 at d>=radius)
linear (t): t sharpest, triangular
quadratic(t): tΒ² eases out β soft falloff
gaussian (t): exp(-((1-t)*2)Β²) bell curve β narrow centre, soft tailsStage 3 β axis lerp. For each axis (wght, wdth, slnt, opsz), interpolate between base and peak by the proximity weight:
value = base + (peak - base) * t
font-variation-settings: "wght" 400, "wdth" 100 β at proximity 0
font-variation-settings: "wght" 800, "wdth" 125 β at proximity 1CSS does not have a built-in interpolation for font-variation-settings between arbitrary values, but a transition: font-variation-settings 150ms ease-out interpolates each named axis numerically β which is exactly what we want. The JS layer writes target values; CSS runs the in-betweens for free.
radius = 120px
βββββββββββββββ
β β letters outside radius β base
β β β cursor at centre β peak axes locally
β β±ββ² β
β β± β β² β letters inside radius β falloff curve
β β± β β² β determines how 'spiky' the centre is
βββββββββββββββPerformance
- rAF throttling. A flood of
pointermoveevents (some browsers fire 200+ per second on a fast trackpad) coalesces into at most one DOM-write pass per frame.scheduleApplyshort-circuits ifrafHandleis already set. - GPU-friendly.
font-variation-settingsis a paint, not a layout, and the slant fallback is a transform β modern engines composite the effect without reflowing the surrounding text. The wrapper's metrics stay stable. - No cache, no observer. Letter rects are read fresh each frame.
getBoundingClientRectis fast in a hot path; caching would mean tracking resize, scroll, zoom, and font-load events for marginal gain. - Capability gate.
isVariableFontSupported()checksCSS.supports('font-variation-settings: "wght" 400')once at mount; on engines that don't support it, the pointermove handler bails immediately and the letters render flat. - Reduced-motion gate.
prefers-reduced-motion: reducealso bails the handler β letters freeze at base, no inline writes, no CSS transitions.
CSS Animation Strategy
The CSS layer is one rule:
.letter {
display: inline-block;
transition: font-variation-settings var(--vp-transition-ms, 150ms) ease-out;
will-change: font-variation-settings;
}Each axis interpolates independently between the previous and new written value. ease-out matches the proximity feel β fast initial response, soft settle as the cursor stops moving. will-change hints the renderer to keep these elements on a separate layer.
For reduced-motion or unsupported-VF engines, both the JS handler and the CSS transition are disabled, so the phrase reads as static type with whatever base axes were configured.
State Flow Diagram
[mounted]
β capability probe & reduced-motion check
β scheduleApply() once β all letters at baseSettings
βΌ
[resting]
β
β pointermove blur
βΌ β²
[tracking] ββ scheduleApply() βββ β
β β β
β writes font-variation- β β
β settings on every letter β β
β each rAF frame β β
β β β
β pointerleave / blur β β
βΌ β β
[resting] ββββββββββββββββββββββ [reset to baseSettings]
β
βΌ
prefers-reduced-motion / no VF support
ββ pointermove bails; letters never animate.Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
text |
string |
required | Phrase rendered as per-letter spans. |
radius |
number |
120 |
Cursor radius in pixels for the proximity falloff. |
falloffCurve |
'linear' | 'quadratic' | 'gaussian' |
'quadratic' |
Curve shape from peak to base. |
axes |
AxisRange[] |
[{axis:'wght',base:400,peak:800},{axis:'wdth',base:100,peak:125}] |
One or more variable-font axes to drive (AxisRange = { axis: 'wght' | 'wdth' | 'slnt' | 'opsz', base: number, peak: number }). |
transitionMs |
number |
150 |
Duration of the CSS transition between axis writes. |
class |
string |
'' |
Extra classes on the wrapper span. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Browser without variable-font support | isVariableFontSupported() returns false; pointermove handler is a no-op; letters render at their base axes statically. |
User has prefers-reduced-motion: reduce |
Same bail as no-VF; CSS transition is also disabled. |
Surrogate pair / emoji in text |
splitToLetters uses Array.from(text), so emoji and CJK ideographs stay as single tokens. |
radius set to 0 |
falloff returns 0 for any distance; every letter sits at base. |
| Pointer leaves the wrapper | cursorPos = null; next rAF resets every letter to baseSettings. |
| Tab focus into the wrapper | handleFocus plants a virtual cursor at the wrapper centre so keyboard users see the proximity peak; blur restores base. |
| Cursor outside the radius | falloff returns 0; letter renders at base values β no DOM-write churn for off-axis letters because they all converge to the same string. |
| Custom font without all the requested axes | The axis tag is silently ignored by the renderer for that font; other axes still drive normally. |
Dependencies
- Svelte 5.x β
$state,$derived,onMount,untrack,SvelteMapfromsvelte/reactivity. - A variable font β bundled fallback chain: Inter Variable, Roboto Flex, Segoe UI Variable, San Francisco β all of which ship on modern OSes. No font CDN.
CSS.supports(native) β capability gate forfont-variation-settings.- Zero external dependencies otherwise.
File Structure
src/lib/components/VariableProximity.svelte # implementation
src/lib/components/VariableProximity.md # this file (rendered inside ComponentPageShell)
src/lib/components/VariableProximity.test.ts # vitest unit tests for the pure helpers
src/routes/variableproximity/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
text | string | required | Phrase to render as proximity-reactive letters. |
axes | { axis, base, peak }[] | wght+wdth | Variable-font axes to morph and their range. |
radius | number | 120 | Cursor influence radius in px. |
falloffCurve | "linear" | "quadratic" | "gaussian" | "quadratic" | Distance-to-strength mapping. |
transitionMs | number | 120 | CSS transition duration on the variation axes. |