The default arrow cursor works, but it doesn’t leave a lasting impression. If you want your website to feel premium, interactive, and memorable, a custom cursor is one of the smallest visual changes you can make with the biggest perceived impact.
In this tutorial, we’ll walk through how to build a smooth, animated custom cursor with CSS and a lightweight JavaScript script. We’ll also cover the things most tutorials skip: accessibility, performance, and what to do on touch devices.
What Is a Custom Cursor?
A custom cursor replaces the browser’s default mouse pointer with something you design yourself. It can be:
- A static image (PNG, SVG, or CUR file) loaded through the CSS
cursorproperty. - An animated HTML element that follows the pointer using JavaScript.
- A combination of both, with trailing effects or hover interactions.
The first method is simple but limited. The second is what modern portfolio and agency websites use, and it’s what we’ll focus on here.

Method 1: The Quick CSS-Only Custom Cursor
If you just want to swap the arrow for a custom image, CSS alone is enough:
body {
cursor: url('/assets/cursor.png') 16 16, auto;
}
a, button {
cursor: url('/assets/cursor-pointer.png') 16 16, pointer;
}
The two numbers after the URL define the hotspot coordinates (the exact pixel that acts as the click point). Always provide a fallback keyword like auto or pointer so the browser has something to fall back on if the image fails to load.
Requirements and Limits
| Property | Value |
|---|---|
| Max size (Chrome/Firefox) | 128 x 128 px |
| Supported formats | PNG, SVG, CUR, GIF (static) |
| Animated cursors | Not supported via CSS |
| Fallback keyword | Required |
If you need animation, scaling, or blend modes, you need JavaScript.
Method 2: An Animated Custom Cursor With CSS and JavaScript
Here’s the approach: we render a real HTML element on the page, hide the native cursor, and update the element’s position on every mouse move.
Step 1: Add the HTML
<div class="cursor" id="cursor"></div>
<div class="cursor-dot" id="cursor-dot"></div>
Two elements give us a layered effect: a large soft ring and a small precise dot.
Step 2: Style It With CSS
* { cursor: none; }
.cursor {
position: fixed;
top: 0;
left: 0;
width: 40px;
height: 40px;
border: 2px solid #111;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
transition: width 0.2s, height 0.2s, background-color 0.2s;
z-index: 9999;
}
.cursor-dot {
position: fixed;
top: 0;
left: 0;
width: 6px;
height: 6px;
background: #111;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
z-index: 9999;
}
.cursor.hover {
width: 70px;
height: 70px;
background: rgba(0, 0, 0, 0.1);
}
A few things to note:
- pointer-events: none ensures the cursor element never blocks clicks.
- position: fixed keeps it aligned with the viewport, not the document.
- transform: translate(-50%, -50%) centers the element on the actual mouse position.
Step 3: Add the JavaScript
const cursor = document.getElementById('cursor');
const dot = document.getElementById('cursor-dot');
let mouseX = 0, mouseY = 0;
let ringX = 0, ringY = 0;
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
dot.style.transform = `translate(${mouseX}px, ${mouseY}px) translate(-50%, -50%)`;
});
function animate() {
ringX += (mouseX - ringX) * 0.15;
ringY += (mouseY - ringY) * 0.15;
cursor.style.transform = `translate(${ringX}px, ${ringY}px) translate(-50%, -50%)`;
requestAnimationFrame(animate);
}
animate();
document.querySelectorAll('a, button, [data-cursor-hover]').forEach(el => {
el.addEventListener('mouseenter', () => cursor.classList.add('hover'));
el.addEventListener('mouseleave', () => cursor.classList.remove('hover'));
});
The dot follows the mouse instantly, while the ring uses linear interpolation (lerp) for a smooth trailing effect. The requestAnimationFrame loop keeps the animation buttery smooth without hammering the browser.

Handling Touch Devices
Touch devices don’t have a cursor. Showing your custom element on mobile would be pointless and could even cause layout issues. Detect and disable it:
const isTouch = window.matchMedia('(pointer: coarse)').matches;
if (isTouch) {
cursor.style.display = 'none';
dot.style.display = 'none';
document.documentElement.style.cursor = 'auto';
}
The (pointer: coarse) media query is the most reliable way to detect touch-primary devices in 2026, better than the old ontouchstart sniffing.
Accessibility Considerations
This is where most custom cursor tutorials fail their users. Before shipping, consider the following:
- Never remove the cursor entirely without a replacement. Users with low vision rely on visible pointers.
- Respect reduced motion preferences. Disable trailing and animations if the user opts out.
- Maintain sufficient contrast. A pale gray cursor on a white background is invisible.
- Provide a way to disable it. Consider a toggle in your site settings.
- Keep the click target aligned. The visual cursor must match where clicks actually register.
Here’s how to respect reduced motion:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function animate() {
if (prefersReducedMotion) {
ringX = mouseX;
ringY = mouseY;
} else {
ringX += (mouseX - ringX) * 0.15;
ringY += (mouseY - ringY) * 0.15;
}
cursor.style.transform = `translate(${ringX}px, ${ringY}px) translate(-50%, -50%)`;
requestAnimationFrame(animate);
}
Performance Tips
- Use transform, not top/left. Transforms are GPU-accelerated and avoid layout recalculations.
- Avoid box-shadow on the cursor. Large blurs cause paint bottlenecks during rapid movement.
- Throttle nothing on mousemove. Keep mousemove listeners minimal, let requestAnimationFrame handle the rendering.
- Add
will-change: transform. Only if you notice jank, since overuse hurts memory. - Skip the cursor on iframes and video overlays. They often break the illusion.

Bonus: Cursor Trails
If you want a trail effect, spawn multiple small dots on movement and fade them out with CSS transitions. Keep the total DOM elements below 20 to preserve performance on lower-end devices. There’s a good explainer over at css-tricks.com.
Common Mistakes to Avoid
| Mistake | Fix |
|---|---|
Forgetting pointer-events: none |
Cursor blocks clicks on links |
Using position: absolute |
Cursor drifts when scrolling |
| Cursor image over 128px | Browsers reject it silently |
No fallback keyword in cursor: url() |
CSS becomes invalid |
| Ignoring touch devices | Broken UX on mobile |
FAQ
Can I use an animated GIF as a custom cursor in CSS?
No. The CSS cursor property only supports static images. For animation, you must render an HTML element and animate it with JavaScript.
What image format is best for a custom cursor?
SVG for scalability and small file size, PNG when you need pixel-perfect raster art. Keep dimensions at or below 128 x 128 pixels. The point is broken down further in this analysis.
Does a custom cursor hurt SEO?
Not directly. However, if it causes layout shifts, poor accessibility scores, or performance issues on mobile, it can indirectly affect Core Web Vitals.
How do I make the cursor change color over dark backgrounds?
Use mix-blend-mode: difference on the cursor element. It automatically inverts against whatever is behind it.
Should every website have a custom cursor?
No. It fits creative, portfolio, and brand-heavy sites. For utility-focused apps, dashboards, or content-heavy platforms, the native cursor is faster, more accessible, and more predictable.
Wrapping Up
A well-built custom cursor takes less than 50 lines of code, adds real personality to your interface, and, when done properly, doesn’t compromise accessibility or performance. Start with the CSS-only approach if your needs are simple, and reach for the JavaScript version when you want smooth animation, hover states, or trailing effects. Always test on touch devices and respect user preferences, and your cursor will feel like a signature detail rather than a gimmick.
