More Animations Snippets
Custom Cursor — Free HTML CSS JS Snippet
Custom Cursor · Animations · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Custom Cursor — Dot + Lagging Ring via Linear Interpolation

Custom cursor replaces the default OS cursor with a branded dot and lagging ring — the ring follows with a smooth delay creating an elastic feel. It pairs with a cursor-following spotlight effect. Used on creative portfolios, agency sites, and premium dark interfaces.
The lerp (linear interpolation) lag
The dot (8px) is set to the exact mouse position each frame: dot.style.left = mx + "px". The ring (36px) uses lerp: rx += (mx - rx) * 0.12. Each frame the ring moves 12% of the remaining distance to the cursor — it accelerates when far and decelerates as it catches up, creating organic following motion.
cursor: none and pointer-events: none
cursor: none on the stage hides the OS cursor. Both elements have pointer-events: none so mouse events pass through to page elements.
Context-aware hover state
Buttons and links have mouseenter/mouseleave listeners that toggle .hovering on the ring — expanding it to 60px and reducing opacity, communicating a clickable element is under the cursor.
The requestAnimationFrame loop
animateLoop() is called via requestAnimationFrame, creating a persistent animation loop that runs every frame. Inside the loop, mx and my (current mouse position) are read from module-level variables updated by mousemove. The dot moves to the exact position; the ring lerps toward it. Both use transform: translate() for GPU-composited movement with no layout recalculation.
Click feedback state
A mousedown listener adds .clicking to the ring — scaling it down to 0.7 and changing its background to a semi-transparent fill. mouseup removes the class. This gives a physical "press" sensation to the cursor.
When to use a custom cursor
Custom cursors work best on creative, dark-themed pages where the OS cursor is less visible and where the branded element reinforces the product identity. They should always be disabled on touch devices via @media (pointer: coarse) { .cursor-dot, .cursor-ring { display: none } } since touch screens have no cursor.
Accessibility note
Custom cursors can reduce accessibility by hiding the familiar OS pointer. Always maintain cursor: none only on the specific stage element, not on the entire page, so browser UI elements (scrollbars, select dropdowns) keep their native cursor.
Reducing motion preference
For users who have enabled "Reduce Motion" in their OS settings, the lerp animation and cursor ring should be simplified. Add: if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { ring.style.display = "none"; } This hides the lagging ring, keeping only the dot which follows the cursor directly without animation.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to reason about the lerp math from scratch. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why rx += (mx - rx) * 0.12 produces deceleration as the ring approaches the cursor, and why the dot and ring are updated inside a requestAnimationFrame loop rather than directly inside the mousemove listener. The same assistant is useful for optimizing it — ask whether reading mx and my from module-level variables versus a ref matters for performance in a React port, and whether the animation loop should pause entirely when the mouse hasn't moved in a while to save battery on laptops. It's also a good way to extend the cursor: ask it to add a magnetic pull effect where the ring snaps toward the center of whatever button it's hovering, a trailing particle effect behind the dot, or a text-selection beam cursor state for editable content. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a custom cursor with a dot-and-ring pair in plain HTML, CSS, and JavaScript using linear interpolation and requestAnimationFrame — no animation library.
Requirements:
- Two fixed-position, pointer-events: none elements layered above the page: a small dot and a larger ring, both hidden from normal document flow and unaffected by page scroll.
- Hide the real OS cursor with cursor: none on the interactive stage only, not on the entire page, so native browser UI like scrollbars and select dropdowns keep their normal cursor.
- Track the live mouse position in module-level (or ref-based) variables updated by a mousemove listener, without directly setting any element's position inside that listener.
- Run a persistent requestAnimationFrame loop that sets the dot's position to the exact current mouse coordinates every frame, and moves the ring's position toward the mouse coordinates using linear interpolation: increment the ring's position by a fraction (roughly 0.1 to 0.15) of the remaining distance each frame, so the ring visibly lags and eases in behind the dot rather than snapping to it.
- Add a mousedown/mouseup pair that toggles a class shrinking or recoloring the cursor for tactile press feedback, and mouseenter/mouseleave listeners on buttons and links that toggle a class expanding the ring to signal an interactive element.
- Add a media query for pointer: coarse that fully hides the custom cursor elements and restores the native cursor, since touch devices have no persistent pointer to track.
- Initialize the tracked mouse position to the center of the viewport (not 0,0) before the first mousemove event fires, so the cursor doesn't visibly jump in from the top-left corner on page load.Step by step
How to Use
- 1Move the cursor in the previewMove the cursor around the dark stage to see the dot track instantly and the ring follow with a lag. Hover over buttons to see the ring expand.
- 2Change the lag speedUpdate the 0.12 lerp factor in the JS panel. Higher (0.2) responds faster; lower (0.05) lags more.
- 3Change cursor coloursUpdate background on .cursor-dot and border-color on .cursor-ring in the CSS panel.
- 4Add hover state to more elementsUpdate the querySelectorAll selector in the JS to include any elements that should expand the ring.
- 5Change ring hover sizeUpdate width/height on .cursor-ring.hovering in the CSS panel.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Linear interpolation: rx += (mx - rx) * 0.12. Each requestAnimationFrame, rx moves 12% of the remaining distance to the cursor position. When the cursor is far away, 12% of a large gap is a big step. When close, 12% of a small gap is tiny — creating natural ease-out deceleration without an animation library.
mousemove fires at irregular intervals tied to mouse movement speed. requestAnimationFrame fires at the display refresh rate (60fps) regardless of mouse activity. Running lerp in rAF produces consistent smooth motion. If you used mousemove, the ring would stutter during fast mouse movements.
cursor: none only applies to pointer devices. Add @media (pointer: coarse) { .cursor-dot, .cursor-ring { display: none !important; } * { cursor: auto !important; } } to restore the native cursor on touch screens. pointerType in pointer events can also detect touch vs mouse at runtime.
On page load before the first mousemove, the dot and ring are at position 0,0. Fix this by initialising positions before the first frame: let mx = window.innerWidth/2, my = window.innerHeight/2, rx = mx, ry = my. This places both at the screen centre until the first real mouse event.
Add event listeners on specific elements: document.querySelectorAll("img[data-zoomable]").forEach(el => { el.addEventListener("mouseenter", () => ring.classList.add("zoom")); el.addEventListener("mouseleave", () => ring.classList.remove("zoom")); }). CSS .cursor-ring.zoom { transform: scale(1.8); } handles the visual change.
Yes. Use useRef for the dot and ring elements. Use useEffect to add the mousemove listener and start the rAF loop. Store mx/my in useRef (not useState) to avoid re-renders on every mouse move — a critical performance detail for cursor animations that update every frame.