GODRICH

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

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.

cursor: url()hotspot 좌표fallback 커서
.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.

pointermovetransform: translate지연 추적
.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.

pointermove거리 벡터transform: translate
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.

pointermoveopacity 전환라벨 텍스트
.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.

mix-blend-modedifferencez-index
.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.

requestAnimationFrameopacity 트랜지션잔상 점
.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.

:hover 감지transform: scale0.2초
.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.

cursor: none포인터 기기 감지기반 설정
.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.

pointer: coarse미디어쿼리JS 건너뛰기
@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.

Enter the archive password

The password is inside this article. You will find it as you read.