Browse by section

Web Design 日本語

Building a Repelling Cursor Effect

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.js was 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 attraction
  • Math.hypot(dx, dy) is the same as the square-root formula but states the intent
  • Dividing by distance normalises the vector, removing the need for atan2, cos and sin
  • ratio fades the effect with distance, so nothing jumps at the boundary
  • Excluding distance === 0 prevents a division by zero producing NaN

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 querySelectorAll inside 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 setPixelRatio at 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 === 0 or you get NaN
  • Store coordinates in the handler; compute once per frame in requestAnimationFrame
  • Measure reference positions once—do not call getBoundingClientRect() every frame
  • three.min.js was 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-motion and 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.