A repelling effect makes elements move away as the cursor approaches, like magnets with matching poles.
Three things decide whether it works: the direction of the repulsion vector (get it wrong and elements attract instead), throttling with requestAnimationFrame, and handling prefers-reduced-motion.
This article was published in 2024 and revised in September 2026. The original basic example pulled elements toward the cursor rather than away from it, and the Three.js section used a build that no longer exists. Both have been fixed.
- The repulsion direction is “cursor → element”. Computing “element → cursor” gives you attraction
three.min.jswas removed in three.js r161. It is loaded as an ES module now
Sponsored
What a repelling effect is
You track the pointer and, once it comes within a set distance, push the element in the opposite direction. Making the push stronger at closer range produces a magnet-like feel.
| Benefit | Detail |
|---|---|
| Visual appeal | Immediate feedback to input |
| Memorability | Distinctive motion works as branding |
| Signalling | Intuitively marks an area as not-for-touching |
One warning up front: applying this to buttons or links produces an unusable interface—the thing runs away when you try to click it. Keep it to decorative elements.
The basic implementation
See the Pen
Repelling effect 01 by Rin (@rinblog0408)
on CodePen.
Getting the direction right
You want to push along the vector that points from the cursor to the element.
// vector from the element centre to the pointer
const dx = pointerX - centerX;
const dy = pointerY - centerY;
// using this directly moves the element toward the cursor — attraction
transform = `translate(${Math.cos(angle) * power}px, ...)`;
// negate it to repel
transform = `translate(${-Math.cos(angle) * power}px, ...)`;
In practice, normalising the vector is shorter and faster than using trigonometry.
<div class="repel-area">
<div class="circle"></div>
</div>
.repel-area {
position: relative;
height: 100vh;
background: #222;
overflow: hidden;
}
.circle {
position: absolute;
top: 50%;
left: 50%;
width: 50px;
height: 50px;
margin: -25px 0 0 -25px;
background: #f39c12;
border-radius: 50%;
transition: transform .25s ease-out;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.circle { transition: none; }
}
const RADIUS = 120; // distance at which it reacts
const STRENGTH = 60; // maximum displacement
const circle = document.querySelector('.circle');
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let pointerX = 0;
let pointerY = 0;
let ticking = false;
function update() {
ticking = false;
const rect = circle.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// cursor → element: this is the direction to push
const dx = centerX - pointerX;
const dy = centerY - pointerY;
const distance = Math.hypot(dx, dy);
if (distance > RADIUS || distance === 0) {
circle.style.transform = 'translate(0, 0)';
return;
}
// stronger when closer (0 to 1)
const ratio = (RADIUS - distance) / RADIUS;
// unit vector times strength
const moveX = (dx / distance) * STRENGTH * ratio;
const moveY = (dy / distance) * STRENGTH * ratio;
circle.style.transform = `translate(${moveX}px, ${moveY}px)`;
}
if (!reduceMotion) {
document.addEventListener('pointermove', (e) => {
pointerX = e.clientX;
pointerY = e.clientY;
// compute at most once per frame
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
});
}
dx = centerX - pointerX—subtracting the pointer from the element centre. Reverse it and you get attractionMath.hypot(dx, dy)is the same as the square-root formula but states the intent- Dividing by
distancenormalises the vector, removing the need foratan2,cosandsin ratiofades the effect with distance, so nothing jumps at the boundary- Excluding
distance === 0prevents a division by zero producingNaN
Sponsored
Why requestAnimationFrame matters
pointermove can fire over a hundred times a second. Calling getBoundingClientRect() each time forces layout recalculation, which gets expensive as element count grows.
// recomputes on every event (expensive)
document.addEventListener('pointermove', (e) => {
const rect = circle.getBoundingClientRect();
// ...
});
// store coordinates only; draw once per frame
document.addEventListener('pointermove', (e) => {
pointerX = e.clientX;
pointerY = e.clientY;
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
});
Displays refresh at most 60–120 times per second. Anything computed beyond that never reaches the screen.
Varying the strength per element
See the Pen
Repelling effect 02 by Rin (@rinblog0408)
on CodePen.
Making larger elements harder to move suggests mass. Set it per element with data-*.
<div class="repel-area">
<div class="circle small" data-radius="150" data-strength="80" style="top: 30%; left: 20%;"></div>
<div class="circle medium" data-radius="120" data-strength="50" style="top: 50%; left: 50%;"></div>
<div class="circle large" data-radius="100" data-strength="25" style="top: 70%; left: 75%;"></div>
</div>
const circles = [...document.querySelectorAll('.circle')].map((el) => ({
el,
radius: Number(el.dataset.radius) || 120,
strength: Number(el.dataset.strength) || 50,
// measure the reference position once
rect: el.getBoundingClientRect(),
}));
// re-measure when the layout changes
function measure() {
for (const c of circles) {
c.el.style.transform = 'translate(0, 0)';
c.rect = c.el.getBoundingClientRect();
}
}
addEventListener('resize', measure);
function update() {
ticking = false;
for (const c of circles) {
const centerX = c.rect.left + c.rect.width / 2;
const centerY = c.rect.top + c.rect.height / 2;
const dx = centerX - pointerX;
const dy = centerY - pointerY;
const distance = Math.hypot(dx, dy);
if (distance > c.radius || distance === 0) {
c.el.style.transform = 'translate(0, 0)';
continue;
}
const ratio = (c.radius - distance) / c.radius;
const moveX = (dx / distance) * c.strength * ratio;
const moveY = (dy / distance) * c.strength * ratio;
c.el.style.transform = `translate(${moveX}px, ${moveY}px)`;
}
}
- Do not re-measure every frame.
getBoundingClientRect()returns the transformed position, so measuring a moving element drifts the reference point - Reset the transform before re-measuring on resize, or you capture a displaced position
- Do not call
querySelectorAllinside the loop. Build the array once
Sponsored
Three.js particles
See the Pen
Repelling effect 03 by Rin (@rinblog0408)
on CodePen.
Note how loading changed. The old build/three.min.js was deprecated in r150 and removed in r161. Use an ES module.
<!-- old: this file does not exist in r161 or later -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- current: resolve with an import map -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// ...
</script>
Rather than rotating the whole particle cloud with the mouse, pushing only the nearby particles reads as actual repulsion.
import * as THREE from 'three';
const COUNT = 800;
const REPEL_RADIUS = 120;
const REPEL_STRENGTH = 60;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 1, 1000);
camera.position.z = 500;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap the cost on high-DPI screens
document.body.appendChild(renderer.domElement);
// keep the original positions to return to
const base = new Float32Array(COUNT * 3);
const positions = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT * 3; i++) {
base[i] = Math.random() * 800 - 400;
positions[i] = base[i];
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({ color: 0xffffff, size: 4 });
const particles = new THREE.Points(geometry, material);
scene.add(particles);
let mx = 0, my = 0;
addEventListener('pointermove', (e) => {
mx = (e.clientX / innerWidth) * 800 - 400;
my = -((e.clientY / innerHeight) * 800 - 400);
});
function animate() {
for (let i = 0; i < COUNT; i++) {
const ix = i * 3;
const dx = base[ix] - mx;
const dy = base[ix + 1] - my;
const distance = Math.hypot(dx, dy);
if (distance < REPEL_RADIUS && distance > 0) {
const ratio = (REPEL_RADIUS - distance) / REPEL_RADIUS;
const tx = base[ix] + (dx / distance) * REPEL_STRENGTH * ratio;
const ty = base[ix + 1] + (dy / distance) * REPEL_STRENGTH * ratio;
// ease toward the target for smoothness
positions[ix] += (tx - positions[ix]) * 0.15;
positions[ix + 1] += (ty - positions[ix + 1]) * 0.15;
} else {
// drift back to the original position
positions[ix] += (base[ix] - positions[ix]) * 0.08;
positions[ix + 1] += (base[ix + 1] - positions[ix + 1]) * 0.08;
}
}
geometry.attributes.position.needsUpdate = true;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
renderer.render(scene, camera); // draw one static frame
} else {
animate();
}
- Keep the original coordinates in a separate array. Updating only the current positions accumulates drift and they never return home
- Do not forget
needsUpdate = true, or nothing changes on screen - Cap
setPixelRatioat 2. At 3× the rendered area is nine times larger - Interpolate (
* 0.15) rather than snapping to the target
Non-negotiable considerations
prefers-reduced-motion
Cursor-tracking motion is among the hardest kinds of effect for motion-sensitive users. Always check and disable.
const mq = matchMedia('(prefers-reduced-motion: reduce)');
if (!mq.matches) {
startRepelEffect();
}
// respond if the setting changes mid-session
mq.addEventListener('change', () => {
if (mq.matches) stopRepelEffect();
else startRepelEffect();
});
Do not run it on touch devices
Touch has no hover. pointermove does fire while a finger drags, but elements fleeing during a scroll is just obstruction.
if (matchMedia('(hover: hover) and (pointer: fine)').matches) {
startRepelEffect();
}
Never apply it to interactive elements
Do not put this on buttons, links or form controls. An interface that moves away when you reach for it is unusable for anyone with limited motor control. Restrict it to decorative shapes and background particles.
Summary
- The direction is “element centre − pointer”. Reverse it and elements attract
- A normalised vector (
dx / distance) is shorter and faster than trigonometry - Exclude
distance === 0or you getNaN - Store coordinates in the handler; compute once per frame in
requestAnimationFrame - Measure reference positions once—do not call
getBoundingClientRect()every frame three.min.jswas removed in r161. Load three.js as an ES module via an import map- In Three.js, keep original coordinates separately and set
needsUpdate = true - Disable for
prefers-reduced-motionand non-hover devices. Never use it on controls
For animation control generally see the Web Animations API animate() method, and for scroll-linked motion CSS scroll-driven animations.