Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 17.4Advanced ~29 min · shot 4 of 6

Cursor and Pointer Effects, Kept Accessible

In one line

A follower that trails the pointer and a button that leans toward it feel alive, and are easy to make unfair. This topic builds both with frame-rate independent smoothing and keeps them for people with a fine pointer, with keyboard focus given the same polish, and with nothing moving for reduced motion.

0/6 · 0%

Think of it like this

A good doorman. When you walk up, he takes a small step toward you, so you feel noticed. If you come in a wheelchair, or with a pushchair, or you do not want attention, he does nothing odd, and he never takes your hat so you cannot find your own way. Pointer effects are the doorman: small, polite, and optional.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

lerp
Linear interpolation: a value moved a share of the way toward a target. a + (b − a) × k.
pointer media feature
A CSS and matchMedia test for the main input device: hover (can it hover?) and pointer (fine like a mouse, coarse like a finger).
magnetic button
A button that moves a little toward the pointer when it is near.
pointerType
A property of a pointer event: mouse, pen or touch.
:focus-visible
A CSS rule for focus that should be shown, mainly for keyboard users.
stale pointer
A remembered pointer position that is no longer where the element is, because the page moved.

Step by step

01Read the four lines

"A follower" first prints the frame-rate table: per-frame 0.1 leaves 0.2059, 0.0424 and 0.0018 at 30, 60 and 120 Hz, and the time-based rule 0.0025 at all three. The last line says the follower is enabled.

02Move the pointer in, then pause

Move the pointer into the viewfinder: a blue ring follows with a short lag. Press Pause while it trails: it stops mid-way, and Play lets it catch up.

03Use the keyboard

Click an empty part of the page, then press Tab. The ring moves to the first button and stays on it. The browser's own focus outline is still there: the ring is an addition.

04Pull the button

In "Magnetic button" move the pointer close to Reserve: it leans toward you, and goes home when you leave the wrapper. Tab to it: it lifts 2 px with a ring.

05Switch on reduced motion

Press the timeline's Reduced motion switch. The follower line prints false, the ring never appears, and the button does not lean. Focus still gets its plain outline.

Try it yourself

  1. 1

    Make the ring lazier

    In "A follower" change follow(dt, 12) in frame to follow(dt, 4). With a pointer jump of 160 px, the ring is 52.7 px along after 100 ms and 127.7 px after 400 ms (with 12 it was 112 px and 158.7 px). It feels floaty.

  2. 2

    Make the button pull harder

    In "A magnetic button" change every 80, 0.35 (in the pointermove handler and the four show lines) to 80, 0.8. The 10 px line becomes 7.0, the 40 px line 16.0: a 16 px lean on a button 80 px wide looks like it is escaping.

  3. 3

    Turn reduced motion on

    Press Reduced motion on the timeline and read the last console line of "A follower": follower enabled here: false. Move the pointer: the ring never appears.

Code & diagrams

A follower

A ring that trails the pointer with time-based lerp, enabled only for a fine pointer without reduced motion, and moved to the focused button for keyboard users. The real cursor is never hidden.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

30 Hz, gap left after 0.5 s: per-frame 0.1 -> 0.2059, time-based -> 0.0025
60 Hz, gap left after 0.5 s: per-frame 0.1 -> 0.0424, time-based -> 0.0025
120 Hz, gap left after 0.5 s: per-frame 0.1 -> 0.0018, time-based -> 0.0025
follower enabled here: true
A magnetic button

pull() is plain maths on a rectangle, so the four lines below are exact. The pull is measured on the wrapper, which never moves. Keyboard focus adds a ring and a lift.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

pointer 10 px right of the centre: x=3.1 y=0.0
pointer 40 px right of the centre: x=7.0 y=0.0
pointer outside the radius: x=0.0 y=0.0
pointer exactly on the centre: x=0.0 y=0.0
Touch, pen and a moving page

Synthetic pointer events of each type. Touch is ignored, a pen counts as fine, and a scroll re-runs the pull because the page moved under a pointer that did not.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

touch pointer: pull = ''
pen 30 px right: pull = 8.0px 0.0px
mouse 30 right, 20 down: pull = 7.2px 4.8px
after scrolling 60 px, pointer still: pull = 0.6px 1.6px

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Trust touch events

In "Touch, pen and a moving page" delete the line if (e.pointerType !== "mouse" && e.pointerType !== "pen") return; // touch is ignored, a pen is a fine pointer.

terminal
$ Run and read the first console line
── what you'll see ──
touch pointer: pull = '8.0px 0.0px'
(it was: touch pointer: pull = '')

Break #2

Measure the button that is moving

In a copy of the magnetic code, call pull(btn.getBoundingClientRect(), …) instead of wrap.getBoundingClientRect().

terminal
$ Send the same pointer position five times, 400 ms apart (radius 100, strength 0.5, pointer 40 px from the centre)
── what you'll see ──
wrapper: 12.0 12.0 12.0 12.0 12.0
button itself: 12.0 10.1 10.5 10.4 10.4

Myth vs fact

Myth

Hiding the cursor makes a custom cursor feel more polished.

Fact

It only works while your script works. If it throws, lags, or the page is busy, the user has no pointer at all. Show the real cursor and put your effect next to it.

Myth

Hover effects are for everyone with a screen.

Fact

A phone has no hover. (hover: hover) and (pointer: fine) is the test, and the page must be complete without the effect.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    pointer and hover describe the primary input. any-pointer and any-hover say whether any input has the feature. A laptop with a touch screen reports pointer: fine; a tablet with a keyboard and a mouse may still report coarse. Test on the device class you care about.

  • ▸

    A pointer event listener on the document that does work on every move is a cost on slow phones. Store the position and do the maths once in requestAnimationFrame, as the follower does with its loop, instead of writing styles inside pointermove.

  • ▸

    For a pen, pointerType is pen and it can hover above the screen, so it passes the fine test. pressure and tiltX exist for pen effects, but a page that needs a pen to work is a page that fails for everyone else.

Remember this

  1. 1

    The problem this solves: pointer effects (a ring that trails the cursor, a button that leans toward it) add life to a launch page, but they assume things that are often false: that there is a mouse at all, that the person can use it precisely, that they have not asked for less motion, and that nobody uses the keyboard. Each broken assumption is a user who gets a worse page, so the effect must be a bonus on top of a page that already works.

  2. 2

    Smoothing with lerp. A follower does not jump to the pointer: each frame it closes a share of the gap, x += (target − x) × k. This is lerp, linear interpolation. If k is a fixed 0.1 per frame, the follower is faster on a 120 Hz screen than on a 30 Hz one. Measured: the gap left after 0.5 s was 0.2059 at 30 Hz, 0.0424 at 60 Hz and 0.0018 at 120 Hz. Use k = 1 − Math.exp(−speed × dt) and it was 0.0025 at all three. In "A follower", a jump of 160 px to the right put the ring 112 px along after 100 ms and 158.7 px after 400 ms, whatever the frame rate. Speed 4 instead of 12 gave 52.7 px and 127.7 px.

  3. 3

    Sleep when it arrives. The loop asks for another frame only while the ring is more than 0.1 px from its target. When it stops, nothing runs, and the next pointermove starts it again with a fresh last. The first time the pointer is seen the ring snaps there, so it does not fly in from the corner.

  4. 4

    Magnetic buttons. Inside a radius, the button moves a share of the way toward the pointer: pull = (1 − distance ÷ radius) × strength × offset. Measured with radius 80 and strength 0.35: 3.1 px at 10 px from the centre, 7.0 px at 40 px, 0 outside the radius and on the centre. The pull is strongest at half the radius. Measure a wrapper that does not move. If you measure the button itself, its own pull changes the measurement: at radius 100, strength 0.5 and a pointer 40 px from the centre, the wrapper gave 12.0 px every time and the button itself gave 12.0, 10.1, 10.5, 10.4, 10.4.

  5. 5

    Who gets it. matchMedia("(hover: hover) and (pointer: fine)") says the main input can hover and is precise. A phone fails it and a mouse passes it. The query describes only the primary input, so a tablet whose main input is touch fails it even when a pen is attached. A pen can hover above the screen, and each event says so in pointerType, so a pen event is allowed while a touch event is ignored. In our test a touch event moved the button 0 px, and without the check it moved it 8.0 px. The effect must follow the preference: with reduced motion on, the follower was disabled and the ring never showed. And never hide the real cursor: no cursor: none. If the script fails or lags, the user still sees where the pointer is.

  6. 6

    Keyboard focus gets the same polish. Mouse users get a pull toward the pointer; keyboard users get a nudge and a ring when the button receives :focus-visible. In "A follower" the ring moves to the middle of the focused button, and in the magnetic button focus adds a 3 px ring and a 2 px lift. A pointer effect is never the only sign of focus. Test it with the Tab key. A page that moves under a still pointer (scrolling) sends no pointer move the effect can rely on, so the pull goes stale: without a scroll handler the button keeps the pull it had before the page moved (7.2 px, 4.8 px in the example). Recomputing on scroll gave 0.6 px, 1.6 px after a 60 px scroll.

Explain it without notes

01

Why is the smoothing written 1 − Math.exp(−speed × dt) and not a fixed 0.1?

02

How do you keep a pointer effect fair to keyboard, touch and reduced-motion users?

03

Why measure the wrapper for a magnetic button, not the button?

Practice

01

Add a matchMedia listener so the follower turns on and off when the user switches reduced motion while the page is open.

02

Make the magnetic button leave the pull to zero when the page is scrolled, then back on the next pointer move.

Trade-offs

  • ↔

    The effect adds life and costs a listener on every pointer move, a loop while it moves, and care for four groups of users. A launch page might use it on one or two calls to action, not on everything.

Done when you can

  • You can smooth a follower so it behaves the same at 30, 60 and 120 Hz

  • You can limit an effect to fine pointers and to people who allow motion

  • You can give keyboard focus the same polish and keep the real cursor

  • You can find and fix a stale pointer when the page scrolls