Detail 06 · Motion
Proximity: text that leans toward you
Letters that swell as the cursor passes, and the same idea behind the macOS Dock. 8 minutes, 4 demos.
Sujoy Kr. Haldar
01 · The idea
Letting the name notice you
My name is the first thing anyone reads on this site, so I wanted it to feel alive: not a fixed headline, but type that notices you. As the cursor passes, the letters near it grow heavier, like pencil lead pressed a little harder, then relax as you move on.
The trick is restraint. Only letters within a short reach respond, the change is a weight shift rather than a jump in size, and every letter eases into place. It reads as a small surprise, not a gimmick competing with the content.
It needs one thing from the typeface: a variable font. EB Garamond has a weight axis, so every weight between 400 and 800 exists, not just regular and bold. That’s what lets the letters swell smoothly instead of snapping between two styles.
02 · Play with it
The real thing, with the controls out
This is the same component the home page uses. Turn on the guides to see each letter’s reach, change how far it reaches and how heavy it gets, and use the speed presets to slow the easing right down.
Sujoy Kr. Haldar
Pointer moves 0·React re-renders 0
Keep an eye on the corner of the stage: pointer moves climb into the hundreds while React re-renders stay at zero. Drag a slider and it ticks once, because that is a real change of props.
Notice the guide is a vertical band, not a circle. Only horizontal distance counts here: the name is a single line, so how far above or below you are doesn’t matter.
03 · How it’s built
From a cursor to a font weight
Every pointer move runs the same short pipeline for each letter. Six steps, and none of them re-render React: it all happens on motion values, which update the style directly.
1.Track the cursor
One motion value holds the pointer's horizontal position. Leaving sets it to Infinity: far away.
mouseX.set(e.clientX)2.Find each letter
Every letter measures where its own centre is on screen.
getBoundingClientRect()3.Distance → closeness
How near the cursor is, as a number: 1 right on the letter, 0 at the edge of its reach.
1 - distance / radius4.Closeness → weight
Map closeness onto the font's weight range, 400 (regular) to 800 (extra bold).
min + (max - min) * c5.Ease it
A spring chases that target weight, so letters swell and settle instead of jumping.
useSpring(target)6.Draw it
Round to a whole weight and apply it as the letter's font-weight.
style={{ fontWeight }}
The heart of it is step 3, the falloff: how closeness drops as the cursor moves away. Mine is linear, a straight line from heavy to regular. Move along the graph to see one letter’s journey, and compare it with a smooth (cosine) falloff, which stays heavy longer near the cursor and fades out softly at the edge.
distance 40px → closeness 0.50 → weight 600
04 · The code
Two small pieces
The wrapper only tracks the pointer and keeps the text accessible: screen readers get the plain name once, while the animated letters are hidden from them.
return (
<span
className={className}
onPointerMove={(e) => mouseX.set(e.clientX)}
onPointerLeave={() => mouseX.set(Infinity)}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{words.flatMap((word, i) => (i === 0 ? [word] : [" ", word]))}
</span>
</span>
);
}Each letter does the rest: measure, work out its closeness, map that to a weight, and let a spring chase it.
function Letter({ char, mouseX, radius, minWeight, maxWeight, spring }) {
const ref = useRef(null);
const targetWeight = useTransform(mouseX, (x) => {
if (!ref.current || !Number.isFinite(x)) return minWeight;
const { left, width } = ref.current.getBoundingClientRect();
const distance = Math.abs(x - (left + width / 2));
const closeness = Math.max(0, 1 - distance / radius);
return minWeight + (maxWeight - minWeight) * closeness;
});
const smoothWeight = useSpring(targetWeight, spring);
const fontWeight = useTransform(smoothWeight, Math.round);
return (
<motion.span ref={ref} className="inline-block" style={{ fontWeight }}>
{char}
</motion.span>
);
}05 · Decisions
Why it’s built this way
Motion values, not state
The pointer moves 60 to 120 times a second. Keeping it in React state would re-render every letter each time. Motion values skip React and update the style directly.
Only horizontal distance
The name sits on one line, so vertical distance would only make letters react while you’re far above them. One axis is also less work per move.
A spring, not a direct map
Mapping distance straight to weight felt twitchy. A spring gives the letters a little momentum, so they swell and settle like ink spreading.
Accessible from the start
Screen readers get the name as plain text; the animated letters are hidden from them. Letters are grouped by word so the line still wraps naturally.
One trade-off worth naming: every letter measures itself on every move. For a short name that’s nothing, but for a whole paragraph I’d measure once and cache the positions.
06 · The same idea, elsewhere
Proximity is a pattern
Strip away the font and the idea is general: distance → closeness → any property. Change which property, and you get some of the best-known interactions around.
The macOS Dock. The property is size instead of weight, with a smooth falloff. And because it animates the icons’ width, not just their scale, the neighbours get pushed aside to make room.
function DockIcon({ pointerX, base = 48, size = 88, radius = 140 }) {
const ref = useRef(null);
// Same pipeline, different property: closeness → icon size
const target = useTransform(pointerX, (x) => {
const { left, width } = ref.current.getBoundingClientRect();
const distance = Math.abs(x - (left + width / 2));
// Smooth (cosine) falloff: flat near the cursor, soft at the edge
const closeness =
distance >= radius
? 0
: (Math.cos((Math.PI * distance) / radius) + 1) / 2;
return base + (size - base) * closeness;
});
const dimension = useSpring(target, { stiffness: 320, damping: 26 });
// Animating width (not just scale) is what pushes the neighbours aside
return (
<motion.div
ref={ref}
style={{ width: dimension, height: dimension }}
/>
);
}A reactive dot grid. Now in two dimensions: straight-line distance to the cursor, and one closeness value driving three properties at once (scale, opacity and colour). It’s a common texture on landing pages.
function Dot({ cx, cy, pointer, radius = 90 }) {
// Two dimensions now: straight-line distance to the cursor
const target = useTransform([pointer.x, pointer.y], ([x, y]) =>
Math.max(0, 1 - Math.hypot(x - cx, y - cy) / radius)
);
const closeness = useSpring(target, { stiffness: 260, damping: 24 });
// One closeness drives three properties at once
const scale = useTransform(closeness, [0, 1], [1, 3.2]);
const opacity = useTransform(closeness, [0, 1], [0.25, 1]);
const backgroundColor = useTransform(
closeness,
[0, 0.4],
["#a0a0a0", "#d2633d"]
);
return (
<motion.span
style={{ left: cx, top: cy, scale, opacity, backgroundColor }}
/>
);
}You’ll find the same pipeline in Apple Watch’s app grid (bubbles scale near the centre), magnetic buttons (position pulls toward the cursor) and spotlight effects (a mask or glow follows it). Once you see it, you see it everywhere.
07 · Takeaways
What to keep
- Proximity is one pipeline: distance → closeness → property → spring. Swap the property and you have a new interaction.
- The falloff shapes the feel: linear reads as pressure, a smooth curve as magnification.
- Run per-frame interactions on motion values, not React state, and keep the real text accessible.
Sweep slowly across my name, right at the top.