Proximity

DevInteraction

I came across this tweet of a dock where the icons get bigger the closer the cursor is, and I wanted to take that idea a bit further.

The difference with hover is that it's basically on or off, you're either over something or you're not. With proximity it's gradual, so as the cursor gets closer, the elements near it scale up and get a bit darker depending on the distance. I think it makes an interface feel a lot more responsive.

I started where the tweet did, with a dock. If you move your cursor across it, each tile changes size depending on how close you are.

Loading demo...
The tiles scale and darken depending on how far they are from the cursor. Drag the sliders to change the falloff.

The main bit is a falloff function, which takes a distance and gives back a weight between 0 and 1. From there I can map that weight onto pretty much anything, like scale or opacity or colour.

dock.js
onpointermove = (e) => {
  document.querySelectorAll(".dock > *").forEach((el) => {
    const r = el.getBoundingClientRect();
    const d = Math.abs(e.clientX - r.x - r.width / 2);
    const t = Math.max(0, 1 - d / 120); // 1 = on it, 0 = far away
    el.style.scale = 1 + t * 0.5;
  });
};

That snippet calls getBoundingClientRect() for every element on every pointer move, which is a layout read per tile, every frame. So I measure everything once, cache the rectangles in an array, and only measure again when the window gets resized. Each frame then just reads one rect and does the maths off the cache.

dock-cached.js
const dock = document.querySelector(".dock");
let rects = [];

// Measure once. Re-measure only when layout can actually change.
const measure = () => {
  const base = dock.getBoundingClientRect();
  rects = [...dock.children].map((el) => {
    const r = el.getBoundingClientRect();
    return { el, cx: r.left - base.left + r.width / 2 };
  });
};
measure();
addEventListener("resize", measure);

// Hot path: one rect read, then cached math.
dock.onpointermove = (e) => {
  const x = e.clientX - dock.getBoundingClientRect().left;
  for (const { el, cx } of rects) {
    const t = Math.max(0, 1 - Math.abs(x - cx) / 120);
    el.style.scale = 1 + t * 0.5;
  }
};

It looks exactly the same, but it goes from a read per tile every frame down to one. Here they are side by side, so you can switch between them and watch the live counter.

Loading demo...
Reads per frame with and without caching, sampled live. It's the same dock both times.

Variations

Once I had the falloff weight I could use it for pretty much anything, so here are a few variations.

Radial fields. I dropped the horizontal-only bit and used proper 2D distance, so everything in a circle around the cursor changes.

Loading demo...
2D proximity, with a grid of dots that get bigger the closer they are to the cursor.

Magnetism. If I use the weight on position instead, elements move towards the cursor as it gets close. I really like this on nav items and main buttons.

Loading demo...
Elements move towards the cursor, up to the pull distance.

Light. Using it on brightness gives a soft circle of light around the cursor, and the cards near it move up slightly and their edges get lighter.

Loading demo...
A spotlight that follows the cursor over a grid of cards.

So it's really just one function that turns distance into a weight, and then caching the rects and writing the styles directly. It barely costs anything per frame, which I was pretty happy with.

Posted on 31 May 2026
Permalink, All Playground