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.
Here is the whole property to play with. Hover the keyword cursors to see exactly what each one looks like on your own system, then switch to the builder to draw a custom cursor, recolour it, size it, and drag its hotspot into place while the live stage shows the result:
Hover any tile to see the real cursor your OS draws for that keyword. Click a tile to copy its declaration. These are drawn by the operating system, so they match the user’s theme.
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
The maximum custom cursor size browsers accept is 128x128px in Firefox and Chromium, and oversized images are not scaled down, they are ignored entirely (you get the keyword fallback instead). MDN itself recommends staying at 32x32px, and 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.
Putting it together, and undoing it
Here is the whole thing in one declaration: a 32px pen image, the hotspot on the nib, with a sensible keyword fallback.
.draw-area {
cursor: url(/cursors/pen-32.png) 4 28, auto;
}Scope it to a class or a single element like that for a local cursor, or set it on body for the whole page (a scoped cursor is almost always what you want). If the cursor still does not show, the fourth thing to check, after the keyword, the size, and the CSP, is whether the file actually loads: a 404, a wrong relative path, or a mixed-content block (an http: cursor on an https: page) all make the browser fall back to the keyword silently. Open the Network panel and confirm the request returns the image.
To undo a custom cursor, set the property back to a keyword. cursor: auto hands the choice back to the browser (links get the pointer, text gets the I-beam), while cursor: default forces the plain arrow regardless of context. cursor: none hides the pointer entirely, but save that for a deliberate full-screen context, never as decoration.
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.
Browser support and quirks
The keyword cursors in the gallery above are supported in every current engine, and cursor: url() itself has worked without prefixes since the early days. Two groups were the last to standardize: grab/grabbing and zoom-in/zoom-out needed vendor prefixes (-webkit-grab, -moz-grab) in older WebKit, Blink, and Gecko. In any browser you would target today they are unprefixed, but if you still support genuinely old versions, write the prefixed line first and the standard one last so the standard value wins where it is understood.
A handful of behaviours surprise people:
| Situation | What happens | What to do |
|---|---|---|
| Touch screen, no pointer | There is no cursor to restyle, so the property is silently inert. iPadOS with a trackpad shows a circle and honors only text. | Never put essential meaning in a cursor; treat it as progressive enhancement. |
| Image over the size cap | Firefox and Chromium ignore images larger than 128x128px (some platforms cap lower) and fall back to the keyword. | Ship 32x32px. Go larger only after testing each platform you care about. |
| SVG with no intrinsic size | Some engines drop an SVG cursor that has no declared width and height. | Put width and height on the root svg element (the builder above does). |
| SVG on a HiDPI display | Some engines rasterize an SVG cursor to a low-resolution PNG, so it can look soft on retina screens. | For a crisp retina cursor, supply a 2x PNG via image-set() instead of relying on the SVG to scale. |
| Right-click menu or drag in progress | In some browsers the custom cursor reverts to the system default while a context menu is open or during a drag. | Do not build an interaction that depends on the custom image being visible at that moment. |
| Strict Content Security Policy | A data: cursor is refused when your CSP does not allow the data: scheme. | Add data: to img-src (or default-src), or serve the cursor as a real file. |
For a sharp cursor on high-DPI displays, pair a 1x and 2x image with image-set(), keeping a plain url() line first as the fallback:
.zoom {
cursor: url(zoom.png), zoom-in;
cursor: image-set(url(zoom.png) 1x, url(zoom@2x.png) 2x) 8 8, zoom-in;
}Older Safari wants the -webkit-image-set() spelling, so include that line too if you still support it. Note the hotspot numbers (8 8) sit after the image-set(), the same place they go after a url().
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.
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
The most common cause is a missing keyword fallback. cursor: url(my.png) is invalid CSS and the browser drops it; you must write cursor: url(my.png), auto with a keyword at the end. The second most common cause is an oversized image: cursors larger than the browser cap (128px in Firefox) are ignored, so use 32x32px.
The hotspot is the exact pixel that registers the click. By default it is the top-left of your image. Set it with two numbers after the URL, x then y, in pixels: cursor: url(arrow.png) 4 4, auto aims the click at the point (4, 4). Centre it for a crosshair, put it on the tip for a pointer.
32x32px is the safe choice across all browsers and operating systems. Firefox ignores cursors larger than 128x128px and other platforms have their own caps; an oversized image silently falls back to the keyword. Browsers do not scale a too-large cursor down, they drop it.
No. No modern browser animates an image in cursor: url(), including GIF, animated PNG, animated WebP, and SVG SMIL. The spec never required it. For a moving cursor you hide the system cursor and move a JavaScript-positioned element along with the pointer instead.
PNG (with alpha transparency) for raster cursors, or SVG for a sharp, resolution-independent vector cursor. You can inline an SVG as a data URI to skip a network request. The old .cur and .ico formats still work but there is no reason to use them now.
Yes. Chrome, Firefox, Edge, and Safari all support cursor: url() and the keyword cursors. The values grab, grabbing, zoom-in, and zoom-out needed vendor prefixes (-webkit-grab in Safari and old Chrome, -moz-grab in old Firefox), so add a prefixed line first only if you support very old versions. On touch screens there is no pointer to restyle, so the property does nothing; iPadOS with a trackpad shows a circle and honors only text. Treat a cursor as progressive enhancement, never as the only signal.
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
- CSS grab & grabbing cursors, Can I use (browser support)caniuse.com





