All articles
09 · Design & interaction · 2026.10 · 6 min

I stopped chasing the pointer: a native SVG cursor with CSS cursor

On most sites a custom cursor is an element chasing the mouse, and it always lags a little. On this site the browser draws the cursor itself: an SVG data URI in the cursor property, two CSS variables and an x for hidden mode. The JavaScript disc that remains only carries a label.

01The old approach

In the first version a ring followed the system cursor around. A fixed-position element took the target coordinates from pointermove, and a requestAnimationFrame loop moved it a bit closer to that target every frame. It grew over interactive elements and showed a label inside on elements with a data-cur attribute.

It looked nice but had two problems. The first was structural lag: the element is drawn after the event arrives, and with easing added the ring always trails the real pointer. The second was cost: the loop asked for the next frame on its first line, so it ran every frame even while the mouse was still. When the main thread was busy, this ring was the first thing to stutter.

The operating system cursor, on the other hand, is drawn independently of the page's JavaScript load. CSS's cursor property accepts a custom image through url(). So I could leave the movement to the browser and supply only the look.

02Two variables, two cursors

tokens.css has two variables: --c-def and --c-ptr. Their default values are plain keywords: default and pointer. Only when (hover: hover) and (pointer: fine) matches, meaning there is a precise pointer, are these values replaced with SVG data URIs. The default cursor is a cross of four short arms with a small Iris circle in the middle; the cursor on clickable elements is a ring with a Soft dot at its centre.

src/styles/tokens.css (shortened)
:root { --c-def: default; --c-ptr: pointer; }
@media (hover: hover) and (pointer: fine) {
  :root {
    --c-def: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'><path d='M16 4v7M16 21v7M4 16h7M21 16h7' stroke='%230B0C11' stroke-width='3.5'/><path d='M16 4v7M16 21v7M4 16h7M21 16h7' stroke='%23EDECF2' stroke-width='1.5'/><circle cx='16' cy='16' r='3.2' fill='%23A8A1F1' stroke='%230B0C11' stroke-width='1.5'/></svg>") 16 16, default;
    --c-ptr: url("data:image/svg+xml,...") 16 16, pointer;
  }
  .app.mv { --c-def: url("data:image/svg+xml,...") 16 16, default; /* the x */ }
  html, .app { cursor: var(--c-def); }
  .app a, .app button, .app label, .app summary, .app [role='button'], .app input[type='range'] { cursor: var(--c-ptr); }
  .app input[type='text'], .app input[type='search'], .app textarea { cursor: text; }
}

In each SVG the strokes are drawn twice: first a thick Void (#0B0C11) outline, then the actual colour, thinner, on top. That keeps the cursor readable on the dark background as well as on Iris and Soft surfaces. Because the colours sit inside a data URI, the # character is written as %23.

I wrote the image into the CSS as a data URI rather than as a separate file. That way the cursor arrives with the stylesheet, does not wait for a separate request, and sits in the same file where the colours are defined, right below the tokens. When I want to change a colour, I know where to look. The cost is readability: editing a one-line SVG is not as comfortable as editing a separate file. I accepted that; cursors are not something that changes often.

03Hotspot and fallback

The 16 16 after url() is the cursor's click point. The SVG is 32×32, so the hotspot is dead centre; the middle of the cross and the point that gets clicked are the same. Without it the browser would use the top-left corner, and the cursor would look like it sat half an icon away from where it clicks.

The default and pointer after the comma are the fallback. If the browser cannot use the image, it drops to that keyword; the cursor syntax requires a url() list to end with a keyword anyway. Usage is simple: html and .app get --c-def; a, button, label, summary, [role='button'] and range inputs inside .app get --c-ptr. Text fields keep the text cursor, because there the reader really needs to see the caret position. Component styles also use cursor: var(--c-ptr) directly, and some playground stages pick their own cursor, such as ew-resize.

When the browser draws the cursor, there is no loop left to fall behind.

04Touch devices and hidden mode

Touch screens have no cursor to begin with. On devices with a coarse pointer the media query does not match and the variables stay as plain keywords. I did not need a separate device check.

The real benefit of variables showed up in hidden mode. When Mauve mode is on, the .app element gets the mv class, and inside the same media query .app.mv redefines both variables: the default cursor becomes a Mauve x, and the cursor on clickable elements becomes a ring with an x inside. No component changes, the cursor rules stay the same; only the variable's value changes, and the whole page picks up the new cursor.

05The disc that remains

CustomCursor.vue is still there, but its job changed. Some elements carry a data-cur attribute; going by the examples in a comment in shell.css, a label like Open or Copy. The disc appears only while the pointer is over such an element, and it carries the label. The pointermove listener looks for the label; if there is none, it never starts the loop.

src/components/layout/CustomCursor.vue
function loop() {
  raf = 0
  if (!vis || !el.value) return
  rx += (cx - rx) * 0.22
  ry += (cy - ry) * 0.22
  el.value.style.transform = `translate3d(${rx.toFixed(1)}px,${ry.toFixed(1)}px,0)`
  if (Math.abs(cx - rx) > 0.2 || Math.abs(cy - ry) > 0.2) raf = requestAnimationFrame(loop)
}

When there is a label, the disc closes part of the remaining distance each frame, and once the gap drops below 0.2 pixels the loop stops itself. Touch events are ignored; with reduced motion on or a coarse pointer, the listeners are never added. The lag is acceptable here, because the disc is not the cursor but a label next to it. The real cursor is always in place. The cost is clear too: since the cursor is an image, I cannot animate it, for example growing on hover; state changes are instant.

Related articles
  1. 10Pause what you cannot see: sleeping loops offscreen and behind overlaysInterface2026.106 min
  2. 11From design prototype to production code: porting controlled chaos to VueDesign & interaction2026.105 min