← Case study

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.

Try it: the real thing

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.

Our example: the hero name

Sujoy Kr. Haldar

Pointer moves 0·React re-renders 0

Speed

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. 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. 2.Find each letter

    Every letter measures where its own centre is on screen.

    getBoundingClientRect()
  3. 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 / radius
  4. 4.Closeness → weight

    Map closeness onto the font's weight range, 400 (regular) to 800 (extra bold).

    min + (max - min) * c
  5. 5.Ease it

    A spring chases that target weight, so letters swell and settle instead of jumping.

    useSpring(target)
  6. 6.Draw it

    Round to a whole weight and apply it as the letter's font-weight.

    style={{ fontWeight }}
↺ Runs again on every pointer move

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.

The falloff: how distance becomes weight
8004000px80pxdistance from cursor →weight

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.

The wrapper: track the pointer, keep the text readable
  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.

Each letter: measure, map, spring
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.

The macOS Dock
F
N
M
F
M
P
C
N
Speed
One Dock icon, same pipeline
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.

The same idea in 2D: a dot grid
Speed
One dot of the grid, in 2D
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.
See it live on the home page→

Sweep slowly across my name, right at the top.