Blog / Development
How to make an SVG react to the mouse cursor
Eyes that follow the cursor, a part that lifts where you point, a needle that swings towards the mouse: they all rest on the same few lines of JavaScript. Here is the full technique, from pointer events to smoothing, touch support and reduced motion.
By Abdullah NomanOctober 16, 20264 min read

"Eyes that follow the mouse" is one of the most searched small effects on the web, and there are plenty of tutorials for it. Most of them stop at the basic math. This one builds it the way you would ship it: in SVG coordinates, smoothed, working with touch, respecting reduced motion and asleep when off screen.
1. The SVG
Two eyes, each with a white and a pupil. The pupils are the parts we will move. The viewBox defines the drawing's own coordinate system; everything below works in those units.
<svg id="face" viewBox="0 0 400 320" role="img" aria-label="A face whose eyes follow the pointer">
<circle cx="150" cy="160" r="40" fill="#fff" stroke="#0e1014"/>
<circle cx="250" cy="160" r="40" fill="#fff" stroke="#0e1014"/>
<circle class="pupil" data-cx="150" data-cy="160" cx="150" cy="160" r="14" fill="#0e1014"/>
<circle class="pupil" data-cx="250" data-cy="160" cx="250" cy="160" r="14" fill="#0e1014"/>
</svg>
2. Read the pointer in SVG coordinates
Pointer events give you screen pixels. The SVG may be scaled to any size, so convert the position into the drawing's own units with the screen transform matrix. This stays correct however the SVG is sized, scrolled or transformed:
const svg = document.getElementById('face')
function toSvg(e) {
const p = new DOMPoint(e.clientX, e.clientY)
return p.matrixTransform(svg.getScreenCTM().inverse())
}
Use pointermove rather than mousemove: pointer events cover mouse, pen and touch with one listener.
3. Move the pupils
For each eye, find the angle from its centre to the pointer and move the pupil part of the way along it, never further than the edge of the eye:
const pupils = [...svg.querySelectorAll('.pupil')]
const MAX = 22 // how far a pupil may travel from the centre, in SVG units
function look(x, y) {
for (const p of pupils) {
const cx = +p.dataset.cx, cy = +p.dataset.cy
const dx = x - cx, dy = y - cy
const d = Math.min(MAX, Math.hypot(dx, dy) / 6)
const a = Math.atan2(dy, dx)
p.setAttribute('cx', cx + Math.cos(a) * d)
p.setAttribute('cy', cy + Math.sin(a) * d)
}
}
Dividing the distance by 6 makes the pupils move less when the pointer is close, which looks more natural than snapping to the edge at once.
4. Smooth it
Setting the position straight from the event looks twitchy. Instead, keep a target and ease towards it once per frame. Stop the loop when it has arrived, so nothing runs while the pointer is still:
const rest = { x: 200, y: 160 }
let target = { ...rest }, cur = { ...rest }, raf = 0
const reduce = matchMedia('(prefers-reduced-motion: reduce)')
function frame() {
const k = reduce.matches ? 1 : 0.15 // reduced motion: jump, do not glide
cur.x += (target.x - cur.x) * k
cur.y += (target.y - cur.y) * k
look(cur.x, cur.y)
const done = Math.abs(target.x - cur.x) < 0.1 && Math.abs(target.y - cur.y) < 0.1
raf = done ? 0 : requestAnimationFrame(frame)
}
function wake() { if (!raf) raf = requestAnimationFrame(frame) }
svg.addEventListener('pointermove', e => { target = toSvg(e); wake() })
svg.addEventListener('pointerleave', () => { target = { ...rest }; wake() })
Listening on the SVG, not the whole window, keeps the effect local. If you want the eyes to follow the cursor anywhere on the page, listen on window instead; the math is the same.
5. Make touch work
By default a finger dragged across the SVG scrolls the page, and the browser cancels the pointer events. Tell the browser that vertical drags scroll the page and horizontal drags belong to the drawing:
#face { touch-action: pan-y; }
On touch there is no hover, so the drawing's resting state is what most phone users see. More on that in Hover effects on mobile.
6. Sleep when off screen
If you listen on window, the effect runs even when the drawing has been scrolled away. Gate it with an IntersectionObserver:
let visible = false
new IntersectionObserver(([e]) => { visible = e.isIntersecting }).observe(svg)
window.addEventListener('pointermove', e => {
if (!visible) return
target = toSvg(e); wake()
}, { passive: true })
From eyes to whole illustrations
The same three steps (pointer to SVG coordinates, a target, an eased loop) drive almost every pointer effect. Replace look() with whatever the drawing should do: lift the item nearest the pointer, grow bars by distance, rotate a dial. For illustrations with many parts, find the nearest part to the pointer and give its neighbours a smaller share of the movement; that falloff is what makes a figure feel physical rather than mechanical.
Or skip the code
Liniqo Live packages this technique into 500 ready-made hairline figures. Each one is drawn on a 400 × 320 viewBox like the example above, shares one animation loop that stops when nothing moves, sleeps off screen, lets vertical scrolling through on touch and lands at once instead of gliding when the visitor has asked for reduced motion. Embedding one is a single line:
<div data-liniqo="sandbox"></div>
<script src="https://liniqo.com/live/embed.js" async></script>
Pick a figure in the library. 23 are free; all of them come with Pro and Lifetime.
Questions
How do I make SVG eyes follow the mouse?
Listen for pointermove, convert the pointer position into SVG coordinates with getScreenCTM().inverse(), compute the angle from each eye centre with Math.atan2, and move each pupil a limited distance along that angle.
Why use pointer events instead of mouse events?
Pointer events handle mouse, pen and touch with the same listeners, so one code path works on desktop and phones.
How do I stop the effect from blocking scrolling on phones?
Set touch-action: pan-y on the SVG. Vertical drags scroll the page and horizontal drags go to your pointer handler.
How should cursor effects handle reduced motion?
Check matchMedia("(prefers-reduced-motion: reduce)") and jump to the final position instead of easing, or turn the effect off entirely.



