A custom cursor in CSS is one line, and the part everyone gets wrong is the end of it:
.draggable {
cursor: url(grab.png), auto;
}The , auto is not optional. The cursor property takes zero or more url() values followed by a mandatory keyword fallback, and if you leave the keyword off the entire declaration is invalid and the browser drops it. So cursor: url(grab.png) silently does nothing, while cursor: url(grab.png), auto works. That single omission is the number-one reason a custom cursor "isn't applying." The keyword at the end is what the browser falls back to if the image fails to load or the format is not supported, and it has to be there.
This works the same way everywhere. cursor: url() is one of the oldest CSS user-interface features, supported in every engine, no prefixes, no feature query needed.
Set the hotspot (the click point)
By default the cursor's active point, the hotspot, is the top-left corner of your image at (0, 0). That is almost never what you want: if your image is a 32px pointing-hand or a crosshair, the actual click should register at the fingertip or the centre, not the corner. Set it with two space-separated numbers after the URL:
.crosshair {
/* hotspot at x=16, y=16 (the centre of a 32px image) */
cursor: url(crosshair.png) 16 16, crosshair;
}The first number is the x-coordinate, the second is y, both measured in pixels from the top-left of the image. So url(arrow.png) 4 4, auto puts the hotspot near the tip of an arrow whose point sits at (4, 4). Get this wrong and clicks land a few pixels off from where the user thinks they are aiming, which feels broken without being obviously broken.
Keep it small: the size cap
Custom cursors have a size ceiling, and oversized images are not scaled down, they are ignored entirely (you get the keyword fallback instead). Firefox caps custom cursors at 128x128px. Other browsers and operating systems have their own limits, often lower.
The safe answer is 32x32px. That is the size the underlying OS cursor machinery expects, it renders crisply, and it works across every browser and platform without hitting anyone's cap. Reach for a larger image only when you have tested it on the platforms you care about, and even then stay at or under 128px. If your cursor mysteriously falls back to the default, the image being too big is the second thing to check after a missing keyword fallback.
Use PNG or SVG, and forget animation
Use PNG for raster cursors (it has the alpha transparency you need so the cursor is not a square block) or SVG for a resolution-independent vector cursor that stays sharp on high-DPI displays. The legacy .ico/.cur formats still work but there is no reason to reach for them now.
What you cannot do is animate the cursor. No modern browser animates an image used in cursor: url(). GIF, animated PNG, animated WebP, SVG with SMIL: none of them move once they are a cursor. The spec never required animated cursor support and engines never added it, so a "spinning custom cursor" is not a thing CSS gives you. If you genuinely need a moving cursor, you hide the real one and follow the pointer with a JavaScript-positioned element, which is a different (and heavier) technique.
You can inline an SVG as a data URI to avoid a separate request, just remember the trailing keyword:
.pen {
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="10" fill="black"/></svg>') 16 16, crosshair;
}The SVG is loaded in a secure, non-scripting context, so an SVG cursor cannot run JavaScript even if you inline arbitrary markup. But a data: cursor is still subject to your Content Security Policy: if your CSP does not allow the data: scheme (it falls under img-src, or default-src if you have no img-src), the browser refuses to load the cursor and quietly drops to the keyword fallback. That is the third reason a cursor "isn't applying," after a missing keyword and an oversized image. The fix is to add data: to the relevant directive, or serve the cursor as a real file instead of inlining it.
You often do not need an image at all
Before shipping a PNG, check whether a built-in keyword already says what you mean. The keyword cursors cover most real UI states and they match the user's OS theme automatically, which a custom image never will:
.button { cursor: pointer; } /* clickable */
.disabled { cursor: not-allowed; } /* action blocked */
.draggable { cursor: grab; }
.dragging { cursor: grabbing; }
.zoomable { cursor: zoom-in; } /* zoom-out for the reverse */
.col-handle { cursor: col-resize; } /* row-resize, ew-resize, ns-resize, nwse-resize... */
.loading { cursor: wait; } /* or progress for "busy but interactive" */
.help-tooltip { cursor: help; }
.text { cursor: text; }There are around 30 of these (default, move, copy, cell, crosshair, the eight *-resize variants, and more). For "this is clickable" the answer is almost always just cursor: pointer, not a hand PNG.
Do not break usability
A custom cursor is a small touch that goes wrong fast. Two rules:
- Never hide the cursor site-wide.
cursor: noneonbody, or a 1px transparent image, leaves people unable to find their pointer. Usenoneonly in a deliberate full-screen context (a game, a kiosk) where you are drawing your own pointer, never as decoration. - Keep it large enough to see and accurate. A tiny or low-contrast custom cursor is harder to track than the system one, and a wrong hotspot makes every click feel off. The system cursor is tuned for the user's display, theme, and accessibility settings (size, high-contrast, pointer trails); your image overrides all of that. Only replace it when the custom cursor genuinely communicates something the keyword cursors cannot.
One quirk worth knowing: in some browsers the custom cursor reverts to the default while a right-click context menu is open or during a drag. That is browser behaviour you cannot style around, so do not build an interaction that depends on the custom image being visible at that moment.
If you are styling interactive states, CSS filters pair well with cursor changes for hover and active feedback. For matching a cursor to whether an element contains something, see the CSS :has() parent selector, and for generated UI labels and badges, CSS content and attr().
FAQ
See also
- CSS filters: blur, grayscale, brightness, drop-shadow: visual effects that pair well with hover and active cursor states.
- The CSS :has() parent selector: style an element based on what it contains, including driving cursor changes.
- CSS content and attr(): generated content for badges, tooltips, and labels near interactive elements.
Sources
Authoritative references this article was fact-checked against.
- cursor (CSS property), MDN Web Docsdeveloper.mozilla.org
- Using URL values for the cursor property, MDN Web Docsdeveloper.mozilla.org
- Content-Security-Policy: default-src directive, MDN Web Docsdeveloper.mozilla.org





