9 Custom Cursor CSS Effects — One Line, Copy-Paste
A custom cursor css effect swaps the browser's plain arrow for a shape, ring, or trailing dot built from CSS transforms and a few lines of JavaScript
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Custom cursor image
- 02 Ring follows cursor
- 03 Magnetic button pull
- 04 Text label on hover
- 05 mix-blend-mode cursor
- 06 Cursor trail
- 07 Ring grows on hover
- 08 Hide native cursor
- 09 Touch-device fallback
The nine below move from the simplest possible substitution through to interaction-aware effects that watch what's underneath the pointer. 01 and 08 lay the groundwork — hiding the native arrow and putting a shaped element in its place. 02 through 07 build motion on top of that base: a trailing ring, a magnetic button, a following label, a color-inverting blob, a fading trail, and a ring that grows over links. 09 closes the set with the one rule the other eight already depend on — what happens when there's no mouse to track at all. Every grid panel above loops a synthetic pointer path on its own inside a fixed, non-scrollable iframe, so watch it play through rather than reaching for your own mouse.
01Custom cursor image
The .cursor element here stands in for an SVG loaded through cursor: url("pin.svg") 12 12, auto. That trailing 12 12 pair is the hotspot — the exact pixel the browser treats as the pointer's own tip once the custom shape takes over. Get that pair wrong and clicks still land at the image's top-left corner instead of wherever the shape visually points.
.stage { cursor: none; }
// Ships in production as an SVG referenced by
// cursor: url("pin.svg") 12 12, auto;
// — those two numbers are the hotspot: the exact
// pixel inside the image the OS treats as the tip.
.cursor {
position: absolute;
top: 0;
left: 0;
width: 26px;
height: 26px;
border-radius: 50% 50% 50% 4px;
background: $color;
box-shadow: $shadow-press;
transform: translate(var(--x, 60px), var(--y, 40px)) rotate(-45deg);
pointer-events: none;
}
02Ring follows cursor
A small dot jumps straight to the pointer's coordinates on every pointermove, while the ring around it only catches up because its CSS transition is set to 180ms. The dot is instant; the ring is deliberately late. That gap between an instant value and a transitioned one is the entire trick; nothing here is running a requestAnimationFrame loop to fake the delay.
.dot, .ring {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
pointer-events: none;
}
.dot { width: 8px; height: 8px; background: $color; }
.ring {
width: 34px;
height: 34px;
margin: -13px 0 0 -13px;
border: 2px solid $color;
transition: transform 180ms $easing;
}
03Magnetic button pull
On every pointermove the script measures the straight-line distance from the pointer to the button's own center. Once that distance drops under 90px, the button nudges itself along the same vector, scaled down to about a third of the real gap. Wire it to a CTA and the button starts drifting toward the cursor well before anyone actually reaches it.
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
var br = btn.getBoundingClientRect();
var cx = br.left - r.left + br.width / 2;
var cy = br.top - r.top + br.height / 2;
var dx = x - cx, dy = y - cy;
var dist = Math.hypot(dx, dy);
var pull = dist < 90 ? (90 - dist) / 90 : 0;
btn.style.transform =
'translate(' + (dx * pull * .35) + 'px,' + (dy * pull * .35) + 'px)';
});
04Text label on hover
The label tracks the pointer's raw coordinates at all times but only turns visible when card.contains(e.target) is true. That opacity transition is the only thing standing between a label glued to the cursor everywhere and one that reads as attached to a single card. Swap the fixed word for anything shorter than a sentence — View, Drag, Open — and the transform logic underneath never has to change.
.label {
position: absolute;
top: 0;
left: 0;
padding: 6px 12px;
border-radius: $r-pill;
background: $color;
color: #fff;
opacity: 0;
transition: opacity 140ms $easing;
pointer-events: none;
transform: translate(-50%, -140%);
}
05mix-blend-mode cursor
A 70px circle painted with mix-blend-mode: difference sits above two differently colored halves of the stage, and wherever it overlaps either one the color underneath gets inverted rather than covered. mix-blend-mode has shipped in every major browser engine for years now. The only real decision left is picking a blend color light enough to invert cleanly against both a dark and a light half.
.cursor {
position: absolute;
top: 0;
left: 0;
width: 70px;
height: 70px;
margin: -35px 0 0 -35px;
border-radius: 50%;
background: $color;
mix-blend-mode: difference;
pointer-events: none;
}
06Cursor trail
Six spans share one keyframe path but start it at staggered negative animation-delay values, 90ms apart, so at any single instant they're all mid-loop at slightly different points along it. That's the whole illusion of a fading tail, with nothing spawning or removing dots from the DOM. Each span's opacity is written once with a Sass @for loop instead of six repeated selectors.
.trail span {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
}
@for $i from 1 through 6 {
.trail span:nth-child(#{$i}) {
opacity: 1 - $i * .13;
animation-delay: -#{$i * 90}ms;
}
}
07Ring grows on hover
The ring's position still comes from pointermove, but its scale comes from a plain CSS selector: .stage:has(.link:hover) .ring. Growing to 1.6x on top of a link needs no extra hover listener in JavaScript at all. :has() reaching up from the ring to check a sibling link is what makes that possible without wiring every link individually.
.ring {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
border: 2px solid $color;
border-radius: 50%;
transition: transform 180ms $easing;
}
.stage:has(.link:hover) .ring {
transform: scale(1.6);
}
08Hide native cursor
Every other effect on this page depends on one line, cursor: none, which is why it gets its own side-by-side panel here against a zone that keeps the browser's default arrow for comparison. Skip that line and a custom cursor element just draws a second shape floating a few pixels off from wherever the real system arrow already sits.
.zone--default {
background: $subject-cream;
color: $stage-ink;
cursor: auto;
}
.zone--custom {
background: $stage-ink;
color: $subject-cream;
cursor: none;
}
.cursor {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
pointer-events: none;
}
09Touch-device fallback
@media (pointer: coarse) is the switch every one of the other eight demos already carries, and it's checked before any tracking script runs, rather than after. A touch screen never even attaches the pointermove listener the mouse-only panel needs. The pointer media feature is supported across every browser people actually ship today, so there's no fallback-for-the-fallback left to write.
@media (pointer: coarse) {
.panel--mouse { display: none; }
.panel--touch {
opacity: 1;
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.panel.is-demo { animation: none; }
}
Where it breaks — the traps
Every custom cursor element across all nine demos carries pointer-events: none, and that single line is doing more work than it looks like. Drop it from item 07's ring, and the ring itself becomes the thing the mouse is actually hovering over, so .stage:has(.link:hover) never fires. The link's own :hover state can't trigger, because the ring, not the link, now sits directly under the pointer. The same missing line breaks item 03 more quietly: the magnetic button still measures distance correctly, but the invisible cursor dot floating on top of it intercepts the click meant for the button underneath.
Forgetting the @media (pointer: coarse) block causes a failure that's much harder to catch at a desk. Nothing looks wrong until someone opens the page on a phone and finds pointermove simply never fires, leaving the CSS's default state stuck on screen, with no way to clear it by tapping. A fourth, quieter version of the same bug shows up on item 05. Strip pointer-events: none from the 70px blend circle and it becomes the hovered element itself, so any :hover rule written for the banner text underneath stops firing the instant the pointer enters the stage — the exact failure mode item 07 has, two demos later.
None of these four are rendering errors the browser will warn about in its console; each one only shows up as a click, a hover, or a touch tap that quietly does nothing. All nine SCSS files, the matching HTML markup, and the production notes on hotspot coordinates and transition-based lag sit in one archive. It opens once x65p86j3 is typed exactly as it reads on this line, with no extra spaces added.
Accessibility
Reduced motion touches these nine unevenly, because a cursor effect's whole job is motion, and a couple of them have no honest version that keeps working while frozen. 01, 03, and 05 hold their shapes at a static rest position instead of looping — the pointer stand-in stops moving but stays visible in place. 02's ring keeps its position frozen too, and its own 180ms transition is turned off, since a lag effect built entirely on a transition timer has nothing left to demonstrate once nothing is transitioning.
04's label goes further and drops to opacity 0 permanently, because a label that used to fade in only within a bounded window has no honest static middle ground. Better hidden than stuck half-visible. 06 keeps its lead dot static but sets every trail span's opacity straight to 0, removing the ghost tail entirely rather than freezing six overlapping copies in place. 07 stops the ring's grow-on-hover animation but leaves the real :hover-triggered scale from .stage:has(.link:hover) untouched, since that transform is a direct response to input rather than a decorative loop.
09 turns off its panel-crossfade loop and settles on the touch panel staying visible, matching what a real touch device already shows under @media (pointer: coarse), regardless of motion settings. 08 is the outlier: it carries no reduced-motion override at all, because the entire point of that panel is the presence or absence of cursor: none rather than any animation. Its demo dot keeps looping no matter the setting. MDN's prefers-reduced-motion reference covers how operating systems expose this preference to the browser in the first place.
Every other pattern in this collection sits behind the css hub, and what actually ships inside a zip here — versus what stays out of it — is spelled out on the about page.
FAQ
Why is a custom cursor css effect not working at all?
The most common cause is a missing cursor: none on the element the pointer sits over. Without it, the browser keeps drawing its own arrow directly on top of whatever shape the CSS and JavaScript are trying to show, so the effect looks like it silently failed even though every rule fired correctly. A close second is a pointermove listener attached to a small inner element — often the button or link being decorated — instead of the larger container around it. That leaves the cursor frozen the moment the pointer strays outside that smaller box. It is also worth confirming pointer-events: none sits on the fake cursor itself, since without it the shape can block the very click or hover state it was meant to sit on top of.
Does a custom cursor css effect need JavaScript, or is CSS alone enough?
Three of the nine here — the plain image swap, the mix-blend-mode circle, and the cursor: none baseline — are pure CSS and need nothing beyond the cursor property and a couple of selectors. The other six need a small pointermove listener because CSS alone has no way to read where the pointer actually is on the page. The JavaScript in each of those demos does nothing more than write the current x and y into a transform, which a CSS transition or keyframe then carries from there. None of the nine need a framework or a build step beyond compiling the Sass down to plain CSS.
Can a custom cursor css image be an SVG instead of a PNG?
Yes, and an SVG is usually the better pick over a PNG for this exact job. It stays sharp at any cursor size, and its file weight barely grows even if the shape gets more detailed later. Reference it the same way as a bitmap: cursor: url("shape.svg") 12 12, auto. The two trailing numbers still mark the hotspot pixel inside that SVG's own coordinate space, not its rendered size on screen. Keep a plain fallback keyword such as auto or pointer listed right after the URL. A browser that fails to load the file for any reason falls straight through to whatever comes next in that same cursor value.