Variable Proximity
The VariableProximity component creates a text effect that responds to mouse proximity with variable font properties. It requires a font with variable axes (like Inter) to work properly.
Usage
Proximity Effect
Interactive text that responds to cursor proximity for engaging user experiences
Move your cursor around the text to see the proximity effect in action
<template>
<div class="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 rounded-xl max-w-2xl mx-auto">
<div class="text-center mb-8">
<h3 class="text-2xl font-bold mb-3 text-highlighted">
Proximity Effect
</h3>
<p class="text-muted">
Interactive text that responds to cursor proximity for engaging user experiences
</p>
</div>
<link
rel="stylesheet"
href="https://rsms.me/inter/inter.css"
>
<div
class="relative p-8 bg-default rounded-lg shadow"
style="font-family: 'Inter var', 'Inter', sans-serif;"
>
<MVariableProximity
label="Hover Over Me"
class="text-4xl font-bold text-center"
:radius="100"
/>
<div class="mt-6 text-center">
<p class="text-muted text-sm">
Move your cursor around the text to see the proximity effect in action
</p>
</div>
</div>
<div class="mt-6 flex justify-center">
<UButton
color="primary"
variant="solid"
>
<UIcon
name="i-lucide-mouse-pointer"
class="w-4 h-4 mr-2"
/>
Try It Yourself
</UButton>
</div>
</div>
</template>
Examples
Wide weight range
Variable Proximity
<template>
<div class="p-6 bg-elevated rounded-lg">
<h3 class="text-lg font-bold mb-4 text-highlighted">
Variable Proximity
</h3>
<link
rel="stylesheet"
href="https://rsms.me/inter/inter.css"
>
<div
class="relative"
style="font-family: 'Inter var', 'Inter', sans-serif;"
>
<MVariableProximity
label="Hover me! Nuxt Motion Block"
class="text-4xl"
from-font-variation-settings="'wght' 400, 'opsz' 9"
to-font-variation-settings="'wght' 1000, 'opsz' 40"
:radius="120"
falloff="linear"
/>
</div>
</div>
</template>
The two fontVariationSettings strings are the endpoints: characters sit at the
from values and animate toward the to values as the pointer nears them, then
spring back to from on leave. radius decides how much of the line the pointer
influences at once — the component interpolates linearly from 0 at the pointer
to 1 at the radius edge.
Whole-card hover area
The pointer is tracked across the whole card, not just the headline — the ref is passed through `containerRef`.
<template>
<div
ref="cardRef"
class="mx-6 max-w-xl rounded-xl border border-default bg-elevated p-8"
style="font-family: 'Inter var', 'Inter', sans-serif;"
>
<link
rel="stylesheet"
href="https://rsms.me/inter/inter.css"
>
<p class="mb-6 text-sm text-muted">
The pointer is tracked across the whole card, not just the headline —
the ref is passed through `containerRef`.
</p>
<MVariableProximity
label="Design is a conversation"
:container-ref="cardRef"
class="text-3xl font-semibold text-highlighted"
:radius="180"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const cardRef = ref<HTMLElement | null>(null)
</script>
By default the pointer is tracked on the component's own root — a box that
hugs the text. Pass containerRef to move the listening to a larger ancestor,
so the effect starts responding before the cursor reaches the words.
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | - | The text to display with proximity effects. |
class | string | '' | CSS classes to apply to each character. |
fromFontVariationSettings | string | 'wght' 400, 'opsz' 9 | Starting font variation settings. |
toFontVariationSettings | string | 'wght' 1000, 'opsz' 40 | Ending font variation settings. |
containerRef | object | null | Element — or Vue ref — whose bounds define the pointer-tracking area. Falls back to the component's own root when unset. |
radius | number | 100 | Radius of the proximity effect in pixels. Read on every pointer move, so it is safe to change reactively. |
falloff | string | 'linear' | Type of falloff: 'linear', 'exponential', or 'gaussian'. |
ui | object | undefined | Per-slot classes merged over the theme defaults. See UI. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.variableProximity in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative inline-block |
char | - |
Accessibility
Each character is a plain span and the label is read once, in order, with no
live region and no per-character announcement. Spaces render as non-breaking
spaces, so word gaps survive the per-character split.
The effect is pointer-only: it listens to mousemove and mouseleave on the
container, with no keyboard or touch equivalent, and there is no reduced-motion
handling — the spring-back on leave runs either way. That is fine as decoration,
as long as the resting state (fromFontVariationSettings, wght 400 by
default) is legible on its own, which is how the component ships.
Performance
- Every
mousemoveon the container measures the container and then every character withgetBoundingClientRect, computes a distance, and calls motion-v'sanimate()on that character — cost is linear in character count per pointer event. Keep labels short, or widenradiusand accept that fewer moves are needed. - Each per-character animation writes two custom properties (
--vp-wght,--vp-opsz) over0.25s; the spans carrywill-change: font-variation-settings, transformso the browser can promote them.mouseleavesweeps every character back to thefromvalues once, over0.35s. - There are no scroll, resize or
requestAnimationFramehooks: listeners are two plain events attached after mount and removed on unmount. - The
from/tosettings are regex-parsed once at setup;radiusis read live from props on each move.
Notes
- The label is split per character —
label.split('')— with each character in its ownspan, so node count equals label length. fromFontVariationSettingsandtoFontVariationSettingsare parsed once when the component is set up. Changing them later does not re-parse; remount with a changing:keyto switch axes.- The effect needs a font that actually exposes
wghtandopszaxes. On a static font the pointer still animates the custom properties, but nothing visible changes.