Guide
Custom cursors in apps and on the web
Ship custom cursors correctly: CSS hotspot syntax, Windows .cur/.ani formats, macOS/Linux realities, DPI sizing, animation, and accessibility tradeoffs.
Updated
- #cursor
- #css
- #windows
- #animation
- #accessibility
- #ux
- #dpi
Custom cursors are one of the few remaining UI details that most tooling ignores entirely. Done right, they are a tight, intentional branding touch in a desktop app or a spatial affordance cue in a game or drawing tool. Done wrong, they are a blurry 32 px PNG drifting three pixels above where the user's click actually lands. This guide covers the full stack: CSS syntax with correct hotspot positioning, the Windows .cur/.ani format reality, what macOS and Linux actually support, DPI handling, animation options, and the accessibility tradeoffs that should gate your decision to use one at all.
The hotspot — the one concept that makes everything click
A cursor image is not a stamp placed at the pointer's coordinates — it is a frame around the pointer's coordinates. The hotspot is the single pixel within your cursor image that the operating system or browser treats as the actual point of interaction. Everything else in the image is visual decoration around that point.
An arrow cursor's hotspot sits at the arrow tip, in the upper-left corner of the image. A crosshair cursor's hotspot sits dead-center. A text-insertion I-beam has its hotspot on the center vertical axis. If your hotspot is in the wrong position, every click, hover target, and tooltip placement in your UI will feel subtly wrong — users will notice even if they cannot name the problem.
Getting the hotspot right is the first obligation of every cursor design, and every format has a way to record it:
- CSS
cursor: url()takes explicit hotspot coordinates as part of the property value. - Windows
.curand.anifiles store the hotspot coordinates inside the file header. - macOS cursor files encode the hotspot at authoring time; they are not overridable at runtime via CSS in a native context.
Web cursors — CSS cursor with the hotspot
The CSS cursor property accepts one or more custom image URLs, each optionally followed by a hotspot pair, and must always end with a keyword fallback. The fallback is mandatory — if all image sources fail to load or are rejected by the browser, the user must still get a valid cursor.
/* Full syntax with hotspot and mandatory keyword fallback */
.drawing-canvas {
cursor: url('/cursors/pencil.png') 4 28, crosshair;
}
/* Multiple sources: browser takes the first one it can render */
.drag-handle {
cursor:
url('/cursors/grab.svg') 12 8,
url('/cursors/grab.png') 12 8,
grab;
}
The two numbers after the URL — 4 28 in the first example — are the hotspot's x and y coordinates measured in image pixels from the top-left corner. A value of 0 0 puts the hotspot at the top-left pixel; in the pencil example above, 4 28 puts it at the pencil tip near the bottom-left of the cursor frame.
Browser size limits
Browsers restrict custom cursor image dimensions to prevent misuse (a cursor image covering the whole viewport would be an obvious attack vector). The practical limit most widely documented is 128 × 128 pixels for Firefox and Chromium-based browsers. Images exceeding the limit are typically ignored silently, causing the browser to fall through to the next source or the keyword fallback. For guaranteed cross-browser reliability, 32 × 32 pixels is the historically safe maximum and is still the sizing standard that works universally. 64 × 64 is broadly usable for modern desktop browsers if you need higher-DPI detail.
SVG cursors on the web
Modern browsers accept SVG files as cursor images. The appeal is obvious — one SVG at any DPI rather than multiple PNG exports. The practical reality is more cautious: browser support for SVG cursors has historically been inconsistent, and SVGs with embedded scripts or external references are rejected for security reasons. If you use SVG cursors, keep them self-contained (no <image> references, no scripts), and always provide a PNG fallback in the source list:
.precision-tool {
cursor:
url('/cursors/crosshair.svg') 16 16,
url('/cursors/crosshair.png') 16 16,
crosshair;
}
Windows cursors — .cur and .ani
On Windows, the native cursor formats are .cur for static cursors and .ani for animated cursors. Both are binary container formats with a structure similar to the .ico format (they share the same DIB-based image encoding).
Format summary
| Property | .cur |
.ani |
|---|---|---|
| Animation | No | Yes — multiple frames |
| Sizes per file | Multiple (like ICO) | One size per frame |
| Color depth | Up to 32-bit RGBA | Up to 32-bit RGBA |
| Hotspot storage | In file header | Per-frame, in header |
| Frame timing | N/A | Measured in jiffies (1/60 s) |
| Windows support | All versions | All versions |
A .cur file stores one or more bitmaps at different sizes (for example, a 32 × 32 and a 48 × 48 variant in the same file), with the hotspot recorded in the file header rather than as a CSS value. The operating system selects the most appropriate size based on the display DPI setting.
Windows historically displayed cursors at 32 × 32 pixels and resized anything larger or smaller to fit. On high-DPI displays, Windows scales cursor rendering upward, so providing a larger native source (64 × 64 or 128 × 128) prevents blur on 1.5× and 2× scaled displays. The .cur container format supports images from 8 × 8 up to 256 × 256.
Animated cursors (.ani)
.ani files are based on Microsoft's RIFF container format and store multiple cursor frames of the same size and color depth. Each frame has its own display duration measured in jiffies — one jiffy equals 1/60th of a second, so a duration of 6 jiffies gives 10 frames per second, and 3 jiffies gives 20 fps. Frames play in sequence in a loop.
The animation loop rate and the number of frames are both fixed at authoring time. You cannot pause, reverse, or dynamically control the frame rate from application code (unlike CSS animations on the web). This is why .ani cursors are used primarily for thematic or decorative motion — spinning watches, animated arrows — rather than for state-driven feedback where you need precise control.
macOS and Linux realities
macOS
macOS native apps use NSCursor (AppKit) or the cursor APIs in SwiftUI. NSCursor accepts NSImage objects, and the hotspot is set programmatically when creating the cursor instance:
// SwiftUI / AppKit: custom cursor from a bundled image
let image = NSImage(named: "PencilCursor")!
let hotspot = NSPoint(x: 4, y: 28)
let cursor = NSCursor(image: image, hotSpot: hotspot)
cursor.push() // set as the active cursor
There is no equivalent of Windows .cur/.ani on macOS — the OS does not load cursor files at the system theme level from a portable binary format. Cursor images are bundled as ordinary assets (PNG or PDF) inside the app bundle.
Web cursors on macOS follow the same CSS cursor: url() rules as other platforms. Safari's support for custom cursors is consistent with Chrome and Firefox for PNG sources with explicit hotspot values. SVG cursor support in Safari has been more limited; testing against a PNG fallback is wise.
Linux
On Linux (X11 and Wayland), cursor themes are stored in the Xcursor format — a directory structure with cursors organized by name and size, typically installed under ~/.local/share/icons/. Xcursor supports animated cursors (multiple frames with per-frame delay values) and multiple sizes for DPI adaptation.
Web browsers on Linux use the CSS cursor property the same way as on other platforms. Custom PNG cursors with hotspot coordinates work reliably across Chrome, Firefox, and Chromium-based browsers on both X11 and Wayland sessions.
There is no cross-platform native cursor binary format. Windows .cur/.ani files do not work on macOS or Linux natively; Xcursor themes are Linux-specific. If you need a cross-platform custom cursor in a desktop application (Electron, Qt, Flutter), you handle it at the framework level — each framework abstracts the OS cursor API and accepts a platform-agnostic image + hotspot pair.
Sizing and DPI
The sizing challenge for cursors is more compressed than for icons, because cursor images are physically small but physically close to the user's pointer — any blur is immediately visible.
| Display DPI | Recommended cursor size |
|---|---|
| 1× (96 dpi) | 32 × 32 px |
| 1.25× | 40 × 40 px |
| 1.5× | 48 × 48 px |
| 2× (192 dpi) | 64 × 64 px |
For web cursors, you can approximate DPI targeting using CSS media queries:
/* Standard DPI */
.canvas-tool {
cursor: url('/cursors/pencil-32.png') 4 28, crosshair;
}
/* High DPI: supply a 2x image scaled to display at 32px logical size */
@media (min-resolution: 2dppx) {
.canvas-tool {
cursor: url('/cursors/pencil-64.png') 8 56, crosshair;
}
}
Note that the hotspot coordinates must also scale with the image: a hotspot at 4 28 in a 32 × 32 image becomes 8 56 in a 64 × 64 version of the same design.
For Windows .cur files, the correct approach is to include both the 32 × 32 and 64 × 64 bitmaps in the same .cur container; the operating system selects the appropriate size based on the display DPI setting.
Animation
There are three ways to animate a cursor, each with different tradeoffs.
Animated cursor files (.ani on Windows, animated frames in Xcursor on Linux) are simple looping animations defined at authoring time. They require no code and work at the OS level. The constraint is that they cannot respond to application state — the animation runs regardless of what the user is doing.
CSS-driven cursors swapping via class toggle are a practical web technique: define multiple static cursor images at different states in CSS, then use JavaScript to toggle a class on the element when the cursor state should change. This gives you full programmatic control at the cost of a class-change round-trip per state.
.canvas { cursor: url('/cursors/pen-up.png') 4 28, crosshair; }
.canvas.pen-down { cursor: url('/cursors/pen-down.png') 4 28, crosshair; }
JavaScript-positioned cursor overlays replace the native cursor entirely — the real cursor is hidden (cursor: none) and a DOM element (typically a <div> or <canvas>) tracks mouse coordinates and is animated with JavaScript. This approach gives maximum creative freedom, and is common in portfolio sites and interactive experiences, but it comes with significant performance and accessibility costs that are discussed below.
Accessibility and performance
Custom cursors sit in a part of the UI that most accessibility guidance does not address explicitly, but that does not mean they are consequence-free.
Hiding the system cursor (cursor: none) is the most problematic pattern. Screen readers and some switch-access setups rely on the system cursor's position for focus tracking and pointer-based interaction. Replacing the cursor with a DOM element breaks this relationship. If you use cursor: none, you must ensure your cursor overlay accurately reflects the true pointer position at all times and that it does not interfere with the ability to reach all interactive elements.
Animated cursors and vestibular sensitivity. Continuously animating cursor motion — whether from CSS or JavaScript — can trigger discomfort for users with vestibular disorders or motion sensitivity. The prefers-reduced-motion media query is the standard way to respect the user's OS-level preference:
/* Default: allow your custom cursor */
.canvas-tool {
cursor: url('/cursors/pencil.png') 4 28, crosshair;
}
/* When the user prefers reduced motion: fall back to a system keyword */
@media (prefers-reduced-motion: reduce) {
.canvas-tool {
cursor: crosshair;
}
}
For JavaScript cursor overlays, check window.matchMedia('(prefers-reduced-motion: reduce)').matches on load and whenever the preference changes, then disable the animated overlay and restore the system cursor.
Performance. A JavaScript cursor overlay that reads mousemove events and updates a DOM element's position on every event is a common source of jank. Coarsen the update rate with requestAnimationFrame rather than setting CSS transforms directly in the event handler, and avoid triggering layout recalculations (read from the event, write to the element position — never interleave reads and writes inside the animation loop).
Cursor images that are purely decorative. When a cursor overlay element is in the DOM, mark it aria-hidden="true" so screen readers do not announce it or include it in the focus order.
The decision checklist before shipping a custom cursor:
- Does the hotspot land exactly where users expect interaction to register?
- Have you tested at 1× and 2× DPI?
- Is there a keyword fallback if your image fails to load?
- Does
prefers-reduced-motion: reducedisable any animation? - If you hid the system cursor, have you verified keyboard and switch-access navigation still work?
For icon assets that will appear alongside your cursors in a UI kit — and for the broader asset packaging story — see one icon system across platforms and packaging and final delivery.