VariableProximity

Cursor-reactive variable-font typography.

Live demo

01

Move 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.

radius 60
radius 140
radius 220

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
VariableProximity.svelte
<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

03

What 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 tails

Stage 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 1

CSS 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 pointermove events (some browsers fire 200+ per second on a fast trackpad) coalesces into at most one DOM-write pass per frame. scheduleApply short-circuits if rafHandle is already set.
  • GPU-friendly. font-variation-settings is 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. getBoundingClientRect is fast in a hot path; caching would mean tracking resize, scroll, zoom, and font-load events for marginal gain.
  • Capability gate. isVariableFontSupported() checks CSS.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: reduce also 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, SvelteMap from svelte/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 for font-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 page

API

04
PropTypeDefaultDescription
textstringrequiredPhrase to render as proximity-reactive letters.
axes{ axis, base, peak }[]wght+wdthVariable-font axes to morph and their range.
radiusnumber120Cursor influence radius in px.
falloffCurve"linear" | "quadratic" | "gaussian""quadratic"Distance-to-strength mapping.
transitionMsnumber120CSS transition duration on the variation axes.