Hover Is Binary, So I Built near-field
CSS has nothing between 'not hovering' and 'hovering', so every proximity effect gets rebuilt in JavaScript, per component, per project. near-field turns cursor proximity into a CSS input: one hook, three custom properties, and your stylesheet decides what 'near' means.
CSS gives you :hover, and :hover is a switch. The pointer is either on the element or it isn't, so every effect you attach to it either snaps on or stays off. There is no :near(). Nothing in the platform can answer "how close is the cursor to this card, on a scale of 0 to 1?"
That missing number is exactly what the nicest pointer interactions run on. Magnetic buttons, cards that lean toward the cursor, glows that start warming up before you arrive: they all need the continuum between "not hovering" and "hovering". And because CSS can't express it, every implementation rebuilds it in JavaScript, per component, per project, usually with its own mousemove listener and its own idea of easing.
I got tired of rebuilding it, so I made it a primitive: near-field, a small library that exposes cursor proximity as a CSS input. There's a demo site and a lab to play with.
The idea: proximity as a custom property
useNearField returns a callback ref. Attach it to an element and the element carries three CSS custom properties, updated as the pointer moves:
--nf: eased proximity, 0 far away to 1 touching the element--nf-x,--nf-y: a unit vector from the element center toward the pointer
That's the whole contract. The library measures; your CSS decides what proximity means.
import { useNearField } from "near-field";
function Card() {
const ref = useNearField({ radius: 140 });
return (
<div ref={ref} className="card">
...
</div>
);
}.card {
/* drift 8px toward the cursor as it approaches */
transform: translate(
calc(var(--nf-x) * var(--nf) * 8px),
calc(var(--nf-y) * var(--nf) * 8px)
);
}
.card .accent {
/* or fade something in with proximity */
opacity: var(--nf);
}I like this split because it keeps the effect where effects belong. The JavaScript side doesn't know you're building a drift, a glow, or a scale. It hands you a number, and the stylesheet composes it with calc() like any other value. Swapping an effect means editing CSS, not rewriting a hook.
For things CSS genuinely can't express, there's a callback mode that hands you the raw frame (proximity, distance, vector, local pointer position) so you can drive a canvas, a WebGL uniform, or an SVG filter instead.
One field, not one listener per element
The obvious implementation is a mousemove listener inside the hook. With one magnetic button that's fine. With a grid of thirty cards it's thirty listeners, thirty getBoundingClientRect() calls per pointer event, and layout thrash exactly when the main thread is busiest.
near-field runs one module-level pointer field instead: a single passive pointermove listener and a single shared requestAnimationFrame flush, no matter how many elements subscribe. Elements register on mount, and distances are computed against cached rects. The rects refresh through ResizeObserver and on scroll end, never per frame, so the hot path is pure arithmetic.
Writes are cheap too. Each element gets its custom properties set via style.setProperty outside React's render cycle, and only when the frame actually changed past a small epsilon. Zero re-renders; React never hears about pointer movement.
Measuring "near" from the edge, not the center
An early version measured distance from the element's center, and it felt wrong in a way that took a while to name: wide elements were penalized. Standing at the end of a long button, the cursor was "far" even while touching it.
So proximity is measured from the nearest edge of the element, clamped to 0 inside it, then eased with smoothstep over the radius:
const dx = Math.max(rect.left - pointerX, 0, pointerX - right);
const dy = Math.max(rect.top - pointerY, 0, pointerY - bottom);
const distance = Math.hypot(dx, dy);
const t = 1 - Math.min(distance / radius, 1);
const proximity = t * t * (3 - 2 * t); // smoothstepThe smoothstep matters more than it looks. Raw linear proximity gives effects a hard "edge" at the radius boundary where the value starts changing at full speed from zero. Eased, the field fades in and out with zero slope at both ends, and everything built on top of it inherits that softness for free.
A 30 line spring instead of an animation library
The packaged effects need spring physics (a magnetic button that snaps back linearly feels dead), and my options were adding a dependency on an animation library or writing an integrator. The integrator won: it's about 30 lines of semi-implicit Euler.
The one decision worth stealing is the fixed substep. The spring integrates at 120Hz internally regardless of the display, accumulating real frame time and stepping through it in fixed slices:
carry += Math.min(dtMs, MAX_DT_MS);
const h = STEP_MS / 1000; // fixed 120Hz slice
while (carry >= STEP_MS) {
carry -= STEP_MS;
const acceleration =
(-stiffness * (value - target) - damping * velocity) / mass;
velocity += acceleration * h;
value += velocity * h;
}Integrate with the raw frame delta instead and the same stiffness and damping produce visibly different motion at 60Hz, 120Hz, and 144Hz, because the integration error depends on step size. With a fixed substep, the feel is identical on every display, and tuning becomes portable: gentle is { stiffness: 170, damping: 24 } everywhere.
Nobody wants to tune stiffness anyway, so the components expose presets instead: feel="gentle" | "snappy" | "instant".
The inputs nobody asks about
Three behaviors are built in rather than left as exercises, because this is exactly the code that never gets written in one-off implementations:
- Reduced motion. Positional effects (Magnetic, Tilt) collapse to rest under
prefers-reduced-motion. Glow is opacity-only, so it may opt in to remain. - Touch. There is no "near" on a touchscreen; the pointer teleports. The field never activates on coarse pointers, and the packaged components degrade to a tuned press state instead of doing nothing.
- SSR. Nothing touches
windowat import time, so it's safe in Next.js and friends with React 18 and 19.
The packaged components
For the common cases you shouldn't have to write the CSS at all. Three effects ship with the feel already tuned:
import { Magnetic, Tilt, Glow } from "near-field";
<Magnetic strength={0.3}>
<button>Get started</button>
</Magnetic>
<Tilt max={6}>
<img src="/cover.png" alt="" />
</Tilt>
<Glow color="rgba(120, 170, 255, 0.3)">
<div className="card">...</div>
</Glow>Magnetic pulls its content toward the cursor and springs back when it leaves. Tilt leans toward the pointer, eased in by proximity so the tilt starts before the cursor arrives. Glow is a soft light that tracks the cursor and brightens as it approaches. All three are thin layers over the same field and springs.
Try it
The core is about 1.5KB min+gzip; the whole package with all three components is about 3.5KB, with no dependencies. The field and springs are framework-free internally, so the React hook is a thin adapter and vanilla or Vue adapters stay possible later.
npm install near-fieldPlay with the demo, push the knobs in the lab, or grab it from npm. If you build something with it, I'd genuinely like to see it.
Have a wonderful day.