Core Philosophy
- Hover is binary. Pointer position is continuous, and that is what makes an element feel like it is aware of the user rather than reacting to a switch
- The best pointer effects are small: a slight lean toward the cursor, a fill that follows it, a reveal that tracks it. Large movement reads as a gimmick
- Every pointer effect needs a keyboard and touch answer, because a pointer position does not exist on either
- The effect is decoration. Nothing may be discoverable only by moving a pointer over it
Tracking the pointer
- Listen for
pointermove, notmousemove. One event covers mouse, pen and touch - The event gives viewport coordinates. To relate them to an element, get the element's box and compare against its centre
const box = element.getBoundingClientRect();
const centerX = box.left + box.width / 2;
const centerY = box.top + box.height / 2;
const deltaX = event.clientX - centerX;
const deltaY = event.clientY - centerY;
- Distance is the Pythagorean theorem:
Math.hypot(deltaX, deltaY) - Angle is
Math.atan2(deltaY, deltaX), which is what you want for anything that should point at, lean toward or orbit the cursor - Listen on the window rather than the element when the effect should respond before the pointer arrives, which is the case for proximity and magnetic effects
Proximity: a radius of interest
- An element that tracks the cursor everywhere on the page is distracting in peripheral vision. Give the effect a maximum distance
- Outside the radius the element returns to rest. Inside it, map distance to strength
const MAX_DISTANCE = 150;
const strength = Math.max(0, 1 - distance / MAX_DISTANCE);
- Ease that strength rather than using it raw, so the effect fades in gently at the edge of the radius instead of switching on
- A plain linear normalize from distance to a CSS value has two failure modes. Past the maximum input the output keeps going, so a
scalecrosses zero and renders the element mirrored, growing larger the further the cursor moves away. Clamp the output to its range - Using zero as the minimum input means full strength exists on exactly one pixel, so the effect never settles at the centre. Set a small non-zero minimum, which gives a dead zone pinned at full strength
function clampedNormalize(value, inMin, inMax, outMin, outMax) {
const progress = (value - inMin) / (inMax - inMin);
const mapped = outMin + progress * (outMax - outMin);
const low = Math.min(outMin, outMax);
const high = Math.max(outMin, outMax);
return Math.min(Math.max(mapped, low), high);
}
const scale = clampedNormalize(distance, 10, 100, 1, 0);
- Anything within 10px of the centre holds a scale of 1, anything past 100px holds 0, and no input can push the value outside that pair
- A radius also caps the work, but write the resting values once on the way out before skipping further updates. Skipping immediately strands the element wherever the pointer was when it crossed the boundary
if (distance > MAX_DISTANCE) {
if (!atRest) { resetToRest(); atRest = true; }
return;
}
atRest = false;
- This is what makes an element feel alive rather than mechanically attached to the pointer
Aiming at the cursor
The eyes-follow-the-pointer pattern: a small part of an element slides toward the cursor, within a hard limit.
- Apply a fraction of the pointer delta, around a tenth, so the part leans toward the cursor rather than sticking to it
- Clamping x and y separately traps the movement in a square, which looks wrong near the corners. Clamp the polar distance instead, so the travel limit is a circle
const angle = Math.atan2(deltaY, deltaX);
const distance = Math.min(Math.hypot(deltaX, deltaY) * 0.1, MAX_OFFSET);
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
- Measure the stable container, never the part that moves. Measuring the moving part shifts its own centre on every update, a feedback loop that can flicker between two positions at frame rate
- That flicker is a photosensitivity hazard, not only a visual bug. A measure-what-you-move loop oscillates at frame rate, an order of magnitude past the three flashes in any one second period that WCAG 2.3.1 Three Flashes or Below Threshold sets as its limit
- The criterion is narrower than "anything that flickers". A flash only counts against it once the flashing area and the change in relative luminance pass the general flash and red flash thresholds, so a small, low-contrast wobble can sit under them. Fix the loop regardless: whether a given oscillation clears the threshold is a measurement you would have to run, not a reason to leave it in
Smoothing the motion
- Applying the raw pointer position makes the element snap. It arrives instantly and stops dead, which reads as cheap
- A CSS transition on
transformis the cheapest smoothing and is usually enough. Keep it short, around 100ms to 200ms, or the element lags behind the cursor - A spring is the better answer when the movement should overshoot and settle, for example an element that leans and then rebounds
- A transition only interpolates between two settled states. Retargeting one on every
pointermoverestarts it from scratch each frame, discarding accumulated progress and velocity, so continuous tracking stutters instead of smoothing - For continuous tracking, keep a rendered position that chases the target by a fraction of the remaining distance each frame
let frame;
function tick() {
rendered.x += (target.x - rendered.x) * 0.12;
rendered.y += (target.y - rendered.y) * 0.12;
element.style.translate = `${rendered.x}px ${rendered.y}px`;
frame = requestAnimationFrame(tick);
}
- As written the loop requeues itself forever. Keep the frame id and
cancelAnimationFrame(frame)on teardown, or stop scheduling once the rendered position is within a fraction of a pixel of the target - The fraction is per frame, so the chase converges twice as fast on a 120Hz display as on a 60Hz one. Scale the factor by the frame delta, or use a spring value from an animation library, which owns the timing and velocity for you
- Never ease with a long duration and a slow curve. Input tracking must stay responsive, and smoothing is there to round the edges of the motion, not to delay it
- Outside React, drive the motion with one spring value that you update per event, never a fresh animation per event. Starting a new animation on every
pointermovelooks acceptable on fast hardware and drops batches of frames on a low-end machine - Create the spring once, call its setter in the handler, and write the transform from a change subscription. Motion exposes this as
springValue, which returns a motion value with.set(),.get()and.on("change", fn)
import { springValue } from "motion";
const springX = springValue(0);
const springY = springValue(0);
const stopX = springX.on("change", (x) => {
element.style.translate = `${x}px ${springY.get()}px`;
});
const stopY = springY.on("change", (y) => {
element.style.translate = `${springX.get()}px ${y}px`;
});
function handlePointerMove(event) {
const box = element.getBoundingClientRect();
const targetX = (event.clientX - (box.left + box.width / 2)) * 0.1;
const targetY = (event.clientY - (box.top + box.height / 2)) * 0.1;
springX.set(targetX);
springY.set(targetY);
}
window.addEventListener("pointermove", handlePointerMove);
function teardown() {
stopX();
stopY();
window.removeEventListener("pointermove", handlePointerMove);
}
- Every subscription here needs releasing on teardown, the same as the frame id above.
on("change", fn)returns its own unsubscribe function, so keep both returns, and name the pointer handler so the listener can be removed by reference - In React the equivalent is a motion value wrapped in
useSpring, covered inmotion-hooks.md. Use that rather than reaching for the vanilla API inside a component
Keeping it off the main thread
- Two costs matter: how often the handler runs, and what it does when it runs
getBoundingClientRectis the expensive call, because the browser may need to recalculate layout. Measured on low-end hardware it can reach a few milliseconds, which is significant at pointer-event rates- Do not throttle the
pointermovehandler. Even modest intervals make the effect feel laggy, because a cursor effect wants to update on every event that arrives - Throttle the measurement instead. Wrap
getBoundingClientRectin a throttle so it returns the cached rect between refreshes, and the handler still runs on every event while only the expensive part is rate-limited. Any throttle helper does, a utility library's or a few lines of your own
const getBox = throttle(() => element.getBoundingClientRect(), 500);
window.addEventListener("pointermove", (event) => {
const box = getBox();
const centerX = box.left + box.width / 2;
const centerY = box.top + box.height / 2;
});
- This also beats caching once and refreshing on
scrollandresize. Those events fire at rates comparable topointermove, so they save little, and they miss invalidations that neither event reports, for example an accordion expanding above the element and pushing it down the page - The rect can be stale for up to one throttle interval. That is imperceptible except in the moment right after a scroll, when the effect is briefly anchored to the old position
- Write only
transform,opacityor a custom property that feeds them. Writing layout properties forces reflow on every move - Batch the write into a
requestAnimationFramecallback so several events in one frame produce one update - Never read layout and write styles alternately in the same handler, which is what causes layout thrashing
element.style.setProperty("--x", `${deltaX * strength}px`);
element.style.setProperty("--y", `${deltaY * strength}px`);
Several elements tracking at once
- When several elements track the pointer, do not attach a listener per element. Register them once, each with its own cached box, and iterate the list inside a single
pointermovehandler - This removes per-element listener overhead, not the work itself; the list walk still grows with the count. With a handful of instances neither shape shows up in a profile. If profiling shows dozens of tracked elements blowing the frame budget, move the whole effect to canvas
- Each tracked element needs its own throttled measurement closure. One throttled getter created outside the loop caches a single rect and hands the same box to every element, so they all behave as though they sat in the same place. With one instance the bug is invisible; it appears the moment a second one is added
- Build the getter per element and store it on that element's registry entry, beside its spring values
const items = [...document.querySelectorAll(".tracker")].map((node, index) => {
const jitter = 1 + ((index % 3) - 1) * 0.1;
const config = { stiffness: 200 * jitter, damping: 20 * jitter };
return {
node,
springX: springValue(0, config),
springY: springValue(0, config),
getBox: throttle(() => node.getBoundingClientRect(), 500),
};
});
- The per-instance jitter keeps the group from moving in lockstep. Keep that band narrow, around ten percent either side of the base stiffness and damping. A narrow band reads organic, a wide spread reads broken, as though some instances were misconfigured
querySelectorAllreturns aNodeList, not an array. Indexing andforEachwork,mapandfilterdo not. Spread it as above, or wrap it inArray.from, before reaching for array methods- When several tracked parts belong to one visual object, testing the radius per element makes each part engage at a different moment, which reads as broken rather than alive. Measure the parent for the on and off decision, and the child for the aim
- Compute the distance once, from the parent's centre, to decide whether the group tracks at all. Inside the group each child still computes its own angle and offset from its own box
const groupBox = getGroupBox();
const groupX = groupBox.left + groupBox.width / 2;
const groupY = groupBox.top + groupBox.height / 2;
const groupDistance = Math.hypot(event.clientX - groupX, event.clientY - groupY);
items.forEach((item) => {
if (groupDistance > MAX_DISTANCE) {
item.springX.set(0);
item.springY.set(0);
return;
}
const box = item.getBox();
// aim this item from its own centre
});
Mapping the pointer into an SVG
- SVG has its own coordinate system. Viewport pixels from a pointer event mean nothing inside a
viewBox, so a shape placed at the raw coordinates lands in the wrong spot as soon as the SVG is scaled - Convert with the element's own matrix rather than doing the arithmetic by hand
const point = svg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const local = point.matrixTransform(svg.getScreenCTM().inverse());
- The manual alternative is to normalise the pointer position to a fraction of the element's box and multiply by the viewBox dimensions, which works when the aspect ratio is preserved
- That alternative introduces a constant that can drift: the
viewBoxin the markup and the scale factor in the JavaScript are two copies of the same number, and editing the markup alone breaks the effect silently. Declare the size once in JavaScript and write the attribute from there
const VIEWBOX_SIZE = 100;
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`);
- The matrix conversion has no such constant, which is the main reason to prefer it
- If you read a value back out of the DOM instead, remember
getAttributereturns a string ornull, never a number. Division coerces it quietly, addition concatenates, sosvg.getAttribute("width") + offsetproduces"25016". Convert withNumber()before any arithmetic - Recompute the matrix after a resize. It encodes the current on-screen size
Wipe reveals with clip-path
clip-pathshows the pixels inside a shape and hides everything outside it. Unlike an SVG mask it works on any element, including images and text- A wipe reveals content in place instead of sliding it. Less movement, and the content never crosses other content on its way in
polygon()covers most cases, with each point as a pair of percentages. Animating the points moves the edge
.reveal-target {
clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
transition: clip-path 400ms ease-out;
}
.reveal-trigger:is(:hover, :focus-visible) + .reveal-target {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
- Keep the point count identical between states. Different counts cannot interpolate and the shape jumps
inset()andcircle()are simpler where the shape allows, andcircle()at the pointer position gives a spotlight that follows the cursor- Include
:focus-visiblealongside:hoverin the trigger, or the reveal is unreachable by keyboard - Clipped-away pixels are not just invisible, they are intangible. The hit area shrinks to the visible shape, so a fully hidden element can never trigger its own
:hover. That is why the trigger above is a separate element; a parent button works just as well - Hiding on hover has the reverse trap: as the visible region shrinks away from under the pointer,
:hoverstops matching and the shape springs back, oscillating at frame rate. Keep the hover on a stable parent in that direction too filter: drop-shadow()on the clipped element is painted before the clip runs, so the shadow is trimmed away with the hidden pixels. Move the filter to a wrapper around the clipped element
Anchoring UI to the tap point
A menu or picker that opens exactly where the user tapped rather than in a fixed corner.
- Store the anchor at click time and add the current vertical scroll offset. Pointer coordinates are relative to the viewport, so an anchor stored without
window.scrollYopens the menu at the wrong height as soon as the page has been scrolled
const anchor = {
x: event.clientX,
y: event.clientY + window.scrollY,
};
wrapper.style.position = "absolute";
wrapper.style.left = `${anchor.x}px`;
wrapper.style.top = `${anchor.y}px`;
position: fixedskips the scroll arithmetic and is correct on the frame it opens, but it then stays pinned to the viewport while the page scrolls underneath, so it detaches from whatever it was opened on. Absolute positioning in document coordinates keeps it attached- When options fan out from that point, aim the arc into the thumb's reach, back toward the bottom of the screen, rather than straight out from the anchor. An arc centred on the tap point sends half the options off-screen when the tap is near an edge
- This is a non-modal overlay, so the focus guidance in
docs/ux/dialogs.mddoes not carry over: there is no focus trap here, and the three rules below replace it - Keep hidden actions out of the tab order. Options that have not appeared yet must not be tabbable, or keyboard focus lands on content sitting behind the backdrop
- Handle Escape at the window level rather than on the overlay, because focus may never have entered the overlay at all
- Dismiss when focus leaves either end of the group. Without it, tabbing past the last option strands the user on hidden content
Cursor and click-target details
- Set
draggable="false"on any image inside a click target. Images are draggable by default, so a click that drifts a few pixels starts a native drag instead, which shows a translucent ghost of the image and swallows the click - A custom cursor is loaded like any other image.
cursor: url("cursor.png"), autofetches the file the first time the cursor enters the element, so the fallback keyword shows until it arrives. Inline it as adata:URI and there is nothing to fetch. The keyword fallback is required either way
Touch and keyboard
- There is no pointer position on a touchscreen, and no hover. A tap fires a synthetic hover that then sticks until the next tap somewhere else
- Gate pointer effects on
@media (hover: hover) and (pointer: fine)so touch devices get the resting state - Give every pointer-revealed control a focus equivalent, so keyboard users reach the same content
- Never place information or an action behind a pointer-only interaction
- Keyboard activation fires a real
clickwith meaningless coordinates. Press Enter on a focused button and the handler receivesclientXandclientYof 0, so anything anchored to the cursor opens in the top-left corner for every keyboard user - There is no dedicated API for telling the two apart.
event.detailcarries the click count, and it is 0 when no pointer click produced the event, which is exactly the keyboard case. Branch on it and fall back to the centre of the trigger's own box
button.addEventListener("click", (event) => {
let x = event.clientX;
let y = event.clientY;
if (event.detail === 0) {
const box = button.getBoundingClientRect();
x = box.left + box.width / 2;
y = box.top + box.height / 2;
}
});
Reduced motion
- Check the preference before attaching the listener, not inside the handler, so no work happens at all
const allowsMotion = window.matchMedia("(prefers-reduced-motion: no-preference)");
if (allowsMotion.matches) attachPointerEffect();
- Listen for changes to that media query. People toggle the setting mid-session
- For a reveal, keep the change and drop the movement: fade or show instantly rather than wiping
Common mistakes
- Calling
getBoundingClientRecton everypointermove, which forces layout at input rate - Throttling the
pointermovehandler rather than the measurement inside it, which makes the whole effect feel laggy - Sharing one throttled bounding-box getter across every tracked element, so they all read the same rect and behave as though they were in the same place
- Testing the radius per element within one group, so parts of a single object start tracking at different moments
- Mapping distance without clamping the output, so a
scaleruns negative and the element renders mirrored, growing as the cursor moves away - Creating a new animation on every
pointermoveinstead of updating one spring value, which drops frames on low-end machines - Anchoring UI to
clientYwithout addingwindow.scrollY, so the menu opens at the wrong height after any scroll - Reading
clientXfrom a keyboard-triggered click, where it is 0, so the effect appears in the corner for keyboard users - Leaving an image inside a click target draggable, so a slightly smeared click starts a native drag and never fires
- Tracking the pointer across the entire page with no radius, so the element twitches in peripheral vision
- Applying the raw pointer delta with no smoothing or falloff, which snaps
- Using viewport coordinates inside an SVG, so the effect drifts once the graphic is scaled
- Animating
clip-pathbetween polygons with different point counts, which jumps instead of wiping - Shipping a hover-only reveal with no focus state, which hides content from keyboard users
- Leaving the effect running on touch devices, where a sticky synthetic hover leaves it stuck on