ehsan.blog
~/blog/how-to-build-a-custom-cursor-in-react — zsh
cat how-to-build-a-custom-cursor-in-react.md

How to build a custom cursor with mix-blend-mode in React

·4 min read

One of the small touches on my CRT-terminal portfolio is a custom cursor: a tiny dot with a larger ring trailing it, and it inverts its color against whatever is behind it. It sounds fancy but it’s two <div>s, a mousemove listener, and one magic CSS property. Here’s exactly how it works — including why I don’t use React state to move it.

Two divs: a dot and a ring

The cursor is built from two absolutely-positioned elements. The dot is a small solid circle that sits right under the pointer; the ring is a larger hollow circle that follows a touch more slowly, giving a nice trailing effect.

tsx
// src/App.tsx
const dotRef = useRef<HTMLDivElement>(null)
const ringRef = useRef<HTMLDivElement>(null)

{!isMobile && (
  <>
    <div
      ref={dotRef}
      aria-hidden="true"
      className="custom-cursor cursor-dot fixed pointer-events-none z-9999"
    />
    <div
      ref={ringRef}
      aria-hidden="true"
      className="custom-cursor cursor-ring fixed pointer-events-none z-9998"
    />
  </>
)}

A few deliberate choices here:

  • aria-hidden="true" — the cursor is purely decorative, so we hide it from screen readers. It conveys no information a non-visual user needs.
  • pointer-events-none — the divs must never intercept clicks or hovers, otherwise they’d block the real buttons underneath. This makes them “transparent” to the mouse.
  • {!isMobile && ...} — a fake cursor makes no sense on a touch screen, so we don’t even render it there.
  • fixed with high z-index keeps them pinned to the viewport, above everything else.

Positioning by mutating the DOM directly

Here’s the part that surprises people. I move the cursor by writing to the elements’ style directly, not through React state:

tsx
// src/App.tsx
useEffect(() => {
  const onMouseMove = (e: MouseEvent) => {
    if (dotRef.current) {
      dotRef.current.style.left = `${e.clientX}px`
      dotRef.current.style.top = `${e.clientY}px`
    }
    if (ringRef.current) {
      ringRef.current.style.left = `${e.clientX}px`
      ringRef.current.style.top = `${e.clientY}px`
    }
  }
  window.addEventListener("mousemove", onMouseMove)
  return () => window.removeEventListener("mousemove", onMouseMove)
}, [])

e.clientX/clientY are the pointer’s coordinates in the viewport. We assign them to each element’s left and top.

Why not useState? Because mousemove fires constantly — potentially hundreds of times a second. Calling setState on each event would trigger a React re-render on every one, which is far too much work for something that just needs to nudge a left/top value. Writing straight to .style skips React’s render cycle entirely and updates the DOM node the moment the event arrives. For high-frequency visual updates like a cursor, direct DOM mutation on a ref is the right tool. Refs are React’s official escape hatch for exactly this — the same reason the horizontal-scroll slideshow writes its translateX straight to the DOM rather than through state.

The empty dependency array [] means we attach the listener once on mount and remove it on unmount via the returned cleanup.

The magic property: mix-blend-mode

Now the styling. Both elements share a base class:

css
/* src/index.css */
.custom-cursor {
  position: fixed;
  pointer-events: none;
  z-index: 9999;
  mix-blend-mode: difference;
}

mix-blend-mode: difference is what makes the cursor invert against its background. Instead of drawing the element’s color on top, the browser subtracts colors channel-by-channel between the element and what’s behind it. Over a black background a near-white cursor stays bright; over a white background that same cursor turns dark. You get automatic contrast on any background without writing a single line of logic — the compositor does it for you.

Styling the dot and ring

The two pieces differ only in size and fill:

css
/* src/index.css */
.cursor-dot {
  width: 6px;
  height: 6px;
  background: oklch(0.95 0 0);
  border-radius: 50%;
  transition: transform 0.1s ease;
}
.cursor-ring {
  width: 28px;
  height: 28px;
  border: 1px solid oklch(0.95 0 0 / 0.5);
  border-radius: 50%;
  transition:
    transform 0.18s ease,
    width 0.2s,
    height 0.2s;
}

The dot is a small solid 6px circle; the ring is a larger 28px hollow circle (border, no background). border-radius: 50% makes both perfectly round. The transition on each gives that soft lag as they catch up to your pointer — the ring’s longer duration is what makes it trail behind the dot.

Takeaways

  • Build the cursor from two aria-hidden, pointer-events-none divs so it’s decorative and never blocks clicks.
  • Move it by writing left/top straight to the ref’s .style on mousemove — never setState, which would re-render on every event.
  • mix-blend-mode: difference inverts the cursor against any background for free.
  • Only render it on desktop; a fake cursor is pointless on touch devices.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.