Accessibility

Reduced motion, touch devices, keyboard focus and screen readers — what the library does by default and what you can change.

A cursor follower is decoration. It carries no information, it is invisible to anyone not using a mouse, and it must never be the reason someone cannot use a page. That framing decides every choice on this page.

Reduced motion

The engine reads prefers-reduced-motion and re-reads it when the visitor changes the setting mid-session — a cursor that keeps easing after the preference flips is precisely the complaint the setting exists to register.

Three modes, via the provider's reducedMotion prop:

ModeBehaviour
instant (default)Layers still render, but every follow duration collapses to 0. The cursor tracks the pointer exactly, with no lag and no easing.
disableThe engine returns an inert instance. Nothing renders, nothing registers, no listener is attached.
ignoreAnimate regardless. Only appropriate when the motion is the content itself.

instant is the default because it degrades rather than disappears: the visitor still gets a custom cursor, just one that does not move on its own. The lag is the part that can cause discomfort, not the shape.

<CursorProvider reducedMotion="disable">
  <DotRing />
</CursorProvider>

Touch and coarse pointers

If matchMedia('(pointer: fine)') does not match, the engine is inert: no listeners, no ticker callback, no layers in the DOM. A phone downloads the component and then does nothing with it.

This is checked in an effect, never during render, so server output is identical either way and there is no hydration mismatch. The engine also watches for pointer-capability changes, which is what happens when a mouse is plugged into a tablet.

For a demo that must still show something on a phone, ship a video. This site records one per variant with Playwright and swaps it in on coarse pointers — a dead panel where a demo should be reads as a broken page, and there is no way to run the real thing.

Never intercepting input

Every layer is position: fixed, pointer-events: none, and portalled to document.body. A layer cannot receive a click, cannot swallow a hover, and cannot appear in the accessibility tree — each one is aria-hidden="true".

The native cursor is hidden with cursor: none on the document, or on the scope element in scoped mode, and restored on unmount. Nothing else about pointer behaviour changes: text selection, drag, right-click and middle-click all work as they did.

Keyboard focus

The library does not touch focus, and :focus-visible is never suppressed. The focus ring on this site is deliberately high-contrast:

:focus-visible {
  outline: none;
  box-shadow:
    0 0 0 2px var(--bg),
    0 0 0 4px var(--text-primary);
}

If you are tempted to hide focus rings because the custom cursor looks cleaner without them: the cursor is invisible to the keyboard user whose ring you are removing.

Screen readers

There is nothing to announce. Layers are aria-hidden, so a screen reader never encounters them.

One trap worth naming, because this site walked into it: a decorative preview panel marked aria-hidden must not contain a focusable control. A <button> inside an aria-hidden subtree is still in the tab order, so a keyboard user reaches a control their screen reader will not announce. Demo targets on this site are non-interactive spans for that reason.

Contrast

--cursor-color defaults to currentColor, so a copy-pasted cursor inherits the page's text colour rather than arriving as an invisible white blob on white. Override it per theme, not once globally:

:root {
  --cursor-color: #1a1a18;
}
:root.dark {
  --cursor-color: #f0f0ee;
}

blend is the exception. It defaults to white because mix-blend-mode: difference only inverts against white — the reasoning is on its component page, and it is a deliberate opt-out from the token, not an oversight.