How to build a custom cursor with mix-blend-mode in React
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.
// 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.fixedwith 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:
// 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:
/* 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:
/* 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-nonedivs so it’s decorative and never blocks clicks. - Move it by writing
left/topstraight to the ref’s.styleonmousemove— neversetState, which would re-render on every event. mix-blend-mode: differenceinverts the cursor against any background for free.- Only render it on desktop; a fake cursor is pointless on touch devices.