UI Components Easy
Smooth Cursor
Custom cursor with a dot and ring that smoothly follow the actual cursor with a spring-like lag effect.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
background: #0a0a0a;
color: #f1f5f9;
overflow: hidden;
cursor: none;
}
/* Ensure all interactive elements also hide cursor */
a,
button,
[class*="hoverable"] {
cursor: none;
}
/* Cursor dot — follows fast */
.cursor-dot {
position: fixed;
top: 0;
left: 0;
width: 8px;
height: 8px;
background: #818cf8;
border-radius: 50%;
pointer-events: none;
z-index: 10001;
transform: translate(-50%, -50%);
transition: width 0.2s, height 0.2s, background 0.2s;
box-shadow: 0 0 10px rgba(129, 140, 248, 0.5);
}
.cursor-dot.hovering {
width: 12px;
height: 12px;
background: #a78bfa;
box-shadow: 0 0 20px rgba(167, 139, 250, 0.6);
}
/* Cursor ring — follows with more lag */
.cursor-ring {
position: fixed;
top: 0;
left: 0;
width: 36px;
height: 36px;
border: 1.5px solid rgba(129, 140, 248, 0.4);
border-radius: 50%;
pointer-events: none;
z-index: 10000;
transform: translate(-50%, -50%);
transition: width 0.3s, height 0.3s, border-color 0.3s, opacity 0.3s;
}
.cursor-ring.hovering {
width: 56px;
height: 56px;
border-color: rgba(167, 139, 250, 0.5);
}
/* Cursor trail — faint glow that follows even slower */
.cursor-trail {
position: fixed;
top: 0;
left: 0;
width: 80px;
height: 80px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
}
/* Page content */
.cursor-page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3rem;
}
.cursor-content {
text-align: center;
}
.cursor-title {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.cursor-subtitle {
font-size: clamp(0.875rem, 2vw, 1.125rem);
color: rgba(148, 163, 184, 0.7);
}
.cursor-cards {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.cursor-card {
padding: 1.5rem 2rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
text-align: center;
transition: background 0.2s, border-color 0.2s;
}
.cursor-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(129, 140, 248, 0.2);
}
.cursor-card h3 {
font-size: 1rem;
font-weight: 600;
color: #e0e7ff;
margin-bottom: 0.25rem;
}
.cursor-card p {
font-size: 0.8rem;
color: rgba(148, 163, 184, 0.6);
}
.cursor-link {
color: #818cf8;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 8px;
transition: color 0.2s;
}
.cursor-link:hover {
color: #a78bfa;
}// Smooth Cursor — custom cursor with lerp/spring interpolation
(function () {
"use strict";
var dot = document.getElementById("cursor-dot");
var ring = document.getElementById("cursor-ring");
var trail = document.getElementById("cursor-trail");
if (!dot || !ring || !trail) return;
var mouse = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
var dotPos = { x: mouse.x, y: mouse.y };
var ringPos = { x: mouse.x, y: mouse.y };
var trailPos = { x: mouse.x, y: mouse.y };
var DOT_SPEED = 0.25;
var RING_SPEED = 0.12;
var TRAIL_SPEED = 0.06;
var isHovering = false;
var visible = false;
function lerp(start, end, factor) {
return start + (end - start) * factor;
}
function update() {
// Lerp each element toward mouse position
dotPos.x = lerp(dotPos.x, mouse.x, DOT_SPEED);
dotPos.y = lerp(dotPos.y, mouse.y, DOT_SPEED);
ringPos.x = lerp(ringPos.x, mouse.x, RING_SPEED);
ringPos.y = lerp(ringPos.y, mouse.y, RING_SPEED);
trailPos.x = lerp(trailPos.x, mouse.x, TRAIL_SPEED);
trailPos.y = lerp(trailPos.y, mouse.y, TRAIL_SPEED);
dot.style.left = dotPos.x + "px";
dot.style.top = dotPos.y + "px";
ring.style.left = ringPos.x + "px";
ring.style.top = ringPos.y + "px";
trail.style.left = trailPos.x + "px";
trail.style.top = trailPos.y + "px";
requestAnimationFrame(update);
}
document.addEventListener("mousemove", function (e) {
mouse.x = e.clientX;
mouse.y = e.clientY;
if (!visible) {
visible = true;
dot.style.opacity = "1";
ring.style.opacity = "1";
trail.style.opacity = "1";
}
});
document.addEventListener("mouseleave", function () {
visible = false;
dot.style.opacity = "0";
ring.style.opacity = "0";
trail.style.opacity = "0";
});
document.addEventListener("mouseenter", function () {
visible = true;
dot.style.opacity = "1";
ring.style.opacity = "1";
trail.style.opacity = "1";
});
// Hover detection for interactive elements
var hoverables = document.querySelectorAll(".hoverable, a, button");
hoverables.forEach(function (el) {
el.addEventListener("mouseenter", function () {
isHovering = true;
dot.classList.add("hovering");
ring.classList.add("hovering");
});
el.addEventListener("mouseleave", function () {
isHovering = false;
dot.classList.remove("hovering");
ring.classList.remove("hovering");
});
});
// Click effect — scale ring briefly
document.addEventListener("mousedown", function () {
ring.style.transform = "translate(-50%, -50%) scale(0.8)";
dot.style.transform = "translate(-50%, -50%) scale(1.4)";
});
document.addEventListener("mouseup", function () {
ring.style.transform = "translate(-50%, -50%) scale(1)";
dot.style.transform = "translate(-50%, -50%) scale(1)";
});
// Initially hide until mouse moves
dot.style.opacity = "0";
ring.style.opacity = "0";
trail.style.opacity = "0";
update();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Smooth Cursor</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="cursor-dot" id="cursor-dot"></div>
<div class="cursor-ring" id="cursor-ring"></div>
<div class="cursor-trail" id="cursor-trail"></div>
<div class="cursor-page">
<div class="cursor-content">
<h1 class="cursor-title">Smooth Cursor</h1>
<p class="cursor-subtitle">Move your mouse around — notice the smooth lag</p>
</div>
<div class="cursor-cards">
<div class="cursor-card hoverable">
<h3>Hover Me</h3>
<p>The cursor ring grows on hover</p>
</div>
<div class="cursor-card hoverable">
<h3>Hover Me Too</h3>
<p>Smooth spring interpolation</p>
</div>
<a href="#" class="cursor-link hoverable">Interactive Link</a>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useEffect, useRef, useState, useCallback } from "react";
interface SmoothCursorProps {
dotSize?: number;
ringSize?: number;
dotColor?: string;
ringColor?: string;
dotSpeed?: number;
ringSpeed?: number;
trailSize?: number;
trailSpeed?: number;
children?: React.ReactNode;
}
export function SmoothCursor({
dotSize = 8,
ringSize = 36,
dotColor = "#818cf8",
ringColor = "rgba(129, 140, 248, 0.4)",
dotSpeed = 0.25,
ringSpeed = 0.12,
trailSize = 80,
trailSpeed = 0.06,
children,
}: SmoothCursorProps) {
const dotRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const trailRef = useRef<HTMLDivElement>(null);
const mouseRef = useRef({ x: 0, y: 0 });
const dotPosRef = useRef({ x: 0, y: 0 });
const ringPosRef = useRef({ x: 0, y: 0 });
const trailPosRef = useRef({ x: 0, y: 0 });
const [hovering, setHovering] = useState(false);
const [visible, setVisible] = useState(false);
const [pressing, setPressing] = useState(false);
const animRef = useRef<number>(0);
const lerp = (a: number, b: number, f: number) => a + (b - a) * f;
const animate = useCallback(() => {
const mouse = mouseRef.current;
const dp = dotPosRef.current;
const rp = ringPosRef.current;
const tp = trailPosRef.current;
dp.x = lerp(dp.x, mouse.x, dotSpeed);
dp.y = lerp(dp.y, mouse.y, dotSpeed);
rp.x = lerp(rp.x, mouse.x, ringSpeed);
rp.y = lerp(rp.y, mouse.y, ringSpeed);
tp.x = lerp(tp.x, mouse.x, trailSpeed);
tp.y = lerp(tp.y, mouse.y, trailSpeed);
if (dotRef.current) {
dotRef.current.style.left = `${dp.x}px`;
dotRef.current.style.top = `${dp.y}px`;
}
if (ringRef.current) {
ringRef.current.style.left = `${rp.x}px`;
ringRef.current.style.top = `${rp.y}px`;
}
if (trailRef.current) {
trailRef.current.style.left = `${tp.x}px`;
trailRef.current.style.top = `${tp.y}px`;
}
animRef.current = requestAnimationFrame(animate);
}, [dotSpeed, ringSpeed, trailSpeed]);
useEffect(() => {
const handleMove = (e: MouseEvent) => {
mouseRef.current.x = e.clientX;
mouseRef.current.y = e.clientY;
if (!visible) setVisible(true);
};
const handleLeave = () => setVisible(false);
const handleEnter = () => setVisible(true);
const handleDown = () => setPressing(true);
const handleUp = () => setPressing(false);
// Hover detection
const handleOverCapture = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("a, button, [role='button'], .hoverable")) {
setHovering(true);
}
};
const handleOutCapture = (e: MouseEvent) => {
const related = e.relatedTarget as HTMLElement | null;
if (!related || !related.closest("a, button, [role='button'], .hoverable")) {
setHovering(false);
}
};
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
document.addEventListener("mousedown", handleDown);
document.addEventListener("mouseup", handleUp);
document.addEventListener("mouseover", handleOverCapture, true);
document.addEventListener("mouseout", handleOutCapture, true);
animRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animRef.current);
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseover", handleOverCapture, true);
document.removeEventListener("mouseout", handleOutCapture, true);
};
}, [animate, visible]);
const cursorStyle: React.CSSProperties = { cursor: "none" };
const hoverDotSize = hovering ? dotSize * 1.5 : dotSize;
const hoverRingSize = hovering ? ringSize * 1.6 : ringSize;
return (
<div style={cursorStyle}>
{/* Cursor dot */}
<div
ref={dotRef}
style={{
position: "fixed",
width: hoverDotSize,
height: hoverDotSize,
background: hovering ? "#a78bfa" : dotColor,
borderRadius: "50%",
pointerEvents: "none",
zIndex: 10001,
transform: `translate(-50%, -50%) scale(${pressing ? 1.4 : 1})`,
transition: "width 0.2s, height 0.2s, background 0.2s, transform 0.15s",
boxShadow: `0 0 ${hovering ? 20 : 10}px ${hovering ? "rgba(167,139,250,0.6)" : `${dotColor}80`}`,
opacity: visible ? 1 : 0,
}}
/>
{/* Cursor ring */}
<div
ref={ringRef}
style={{
position: "fixed",
width: hoverRingSize,
height: hoverRingSize,
border: `1.5px solid ${hovering ? "rgba(167,139,250,0.5)" : ringColor}`,
borderRadius: "50%",
pointerEvents: "none",
zIndex: 10000,
transform: `translate(-50%, -50%) scale(${pressing ? 0.8 : 1})`,
transition: "width 0.3s, height 0.3s, border-color 0.3s, transform 0.15s",
opacity: visible ? 1 : 0,
}}
/>
{/* Trail glow */}
<div
ref={trailRef}
style={{
position: "fixed",
width: trailSize,
height: trailSize,
background: `radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)`,
borderRadius: "50%",
pointerEvents: "none",
zIndex: 9999,
transform: "translate(-50%, -50%)",
opacity: visible ? 1 : 0,
}}
/>
{children}
</div>
);
}
// Demo usage
export default function SmoothCursorDemo() {
return (
<SmoothCursor>
<div
style={{
width: "100vw",
height: "100vh",
background: "#0a0a0a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "3rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#f1f5f9",
cursor: "none",
}}
>
<div style={{ textAlign: "center" }}>
<h1
style={{
fontSize: "clamp(2rem, 5vw, 3.5rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
background: "linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
marginBottom: "0.5rem",
}}
>
Smooth Cursor
</h1>
<p style={{ fontSize: "1rem", color: "rgba(148,163,184,0.7)" }}>
Move your mouse around — notice the smooth lag
</p>
</div>
<div
style={{
display: "flex",
gap: "1rem",
alignItems: "center",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{["Hover Me", "Hover Me Too"].map((title) => (
<div
key={title}
className="hoverable"
style={{
padding: "1.5rem 2rem",
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 12,
textAlign: "center",
cursor: "none",
}}
>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
color: "#e0e7ff",
marginBottom: "0.25rem",
}}
>
{title}
</h3>
<p style={{ fontSize: "0.8rem", color: "rgba(148,163,184,0.6)" }}>
The cursor ring grows on hover
</p>
</div>
))}
<a
href="#"
className="hoverable"
onClick={(e) => e.preventDefault()}
style={{
color: "#818cf8",
textDecoration: "none",
fontWeight: 600,
fontSize: "1rem",
padding: "0.5rem 1rem",
borderRadius: 8,
cursor: "none",
}}
>
Interactive Link
</a>
</div>
</div>
</SmoothCursor>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const props = defineProps({
dotSize: { type: Number, default: 8 },
ringSize: { type: Number, default: 36 },
dotColor: { type: String, default: "#818cf8" },
ringColor: { type: String, default: "rgba(129, 140, 248, 0.4)" },
dotSpeed: { type: Number, default: 0.25 },
ringSpeed: { type: Number, default: 0.12 },
trailSize: { type: Number, default: 80 },
trailSpeed: { type: Number, default: 0.06 },
});
const dotEl = ref(null);
const ringEl = ref(null);
const trailEl = ref(null);
const hovering = ref(false);
const visible = ref(false);
const pressing = ref(false);
const mouse = { x: 0, y: 0 };
const dotPos = { x: 0, y: 0 };
const ringPos = { x: 0, y: 0 };
const trailPos = { x: 0, y: 0 };
let animId = 0;
function lerp(a, b, f) {
return a + (b - a) * f;
}
function animate() {
dotPos.x = lerp(dotPos.x, mouse.x, props.dotSpeed);
dotPos.y = lerp(dotPos.y, mouse.y, props.dotSpeed);
ringPos.x = lerp(ringPos.x, mouse.x, props.ringSpeed);
ringPos.y = lerp(ringPos.y, mouse.y, props.ringSpeed);
trailPos.x = lerp(trailPos.x, mouse.x, props.trailSpeed);
trailPos.y = lerp(trailPos.y, mouse.y, props.trailSpeed);
if (dotEl.value) {
dotEl.value.style.left = `${dotPos.x}px`;
dotEl.value.style.top = `${dotPos.y}px`;
}
if (ringEl.value) {
ringEl.value.style.left = `${ringPos.x}px`;
ringEl.value.style.top = `${ringPos.y}px`;
}
if (trailEl.value) {
trailEl.value.style.left = `${trailPos.x}px`;
trailEl.value.style.top = `${trailPos.y}px`;
}
animId = requestAnimationFrame(animate);
}
const hoverDotSize = computed(() => (hovering.value ? props.dotSize * 1.5 : props.dotSize));
const hoverRingSize = computed(() => (hovering.value ? props.ringSize * 1.6 : props.ringSize));
function handleMove(e) {
mouse.x = e.clientX;
mouse.y = e.clientY;
if (!visible.value) visible.value = true;
}
function handleLeave() {
visible.value = false;
}
function handleEnter() {
visible.value = true;
}
function handleDown() {
pressing.value = true;
}
function handleUp() {
pressing.value = false;
}
function handleOverCapture(e) {
if (e.target.closest('a, button, [role="button"], .hoverable')) hovering.value = true;
}
function handleOutCapture(e) {
const related = e.relatedTarget;
if (!related || !related.closest('a, button, [role="button"], .hoverable'))
hovering.value = false;
}
onMounted(() => {
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
document.addEventListener("mousedown", handleDown);
document.addEventListener("mouseup", handleUp);
document.addEventListener("mouseover", handleOverCapture, true);
document.addEventListener("mouseout", handleOutCapture, true);
animId = requestAnimationFrame(animate);
});
onUnmounted(() => {
cancelAnimationFrame(animId);
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseover", handleOverCapture, true);
document.removeEventListener("mouseout", handleOutCapture, true);
});
</script>
<template>
<div style="cursor: none;">
<!-- Cursor dot -->
<div
ref="dotEl"
:style="{
position: 'fixed',
width: hoverDotSize + 'px',
height: hoverDotSize + 'px',
background: hovering ? '#a78bfa' : dotColor,
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 10001,
transform: `translate(-50%, -50%) scale(${pressing ? 1.4 : 1})`,
transition: 'width 0.2s, height 0.2s, background 0.2s, transform 0.15s',
boxShadow: `0 0 ${hovering ? 20 : 10}px ${hovering ? 'rgba(167,139,250,0.6)' : dotColor + '80'}`,
opacity: visible ? 1 : 0,
}"
></div>
<!-- Cursor ring -->
<div
ref="ringEl"
:style="{
position: 'fixed',
width: hoverRingSize + 'px',
height: hoverRingSize + 'px',
border: `1.5px solid ${hovering ? 'rgba(167,139,250,0.5)' : ringColor}`,
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 10000,
transform: `translate(-50%, -50%) scale(${pressing ? 0.8 : 1})`,
transition: 'width 0.3s, height 0.3s, border-color 0.3s, transform 0.15s',
opacity: visible ? 1 : 0,
}"
></div>
<!-- Trail glow -->
<div
ref="trailEl"
:style="{
position: 'fixed',
width: trailSize + 'px',
height: trailSize + 'px',
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 9999,
transform: 'translate(-50%, -50%)',
opacity: visible ? 1 : 0,
}"
></div>
<!-- Demo content -->
<div class="demo">
<div style="text-align: center;">
<h1 class="heading">Smooth Cursor</h1>
<p class="subtitle">Move your mouse around — notice the smooth lag</p>
</div>
<div class="cards">
<div v-for="title in ['Hover Me', 'Hover Me Too']" :key="title" class="card hoverable" style="cursor: none;">
<h3 class="card-title">{{ title }}</h3>
<p class="card-desc">The cursor ring grows on hover</p>
</div>
<a href="#" class="link hoverable" style="cursor: none;" @click.prevent>
Interactive Link
</a>
</div>
</div>
</div>
</template>
<style scoped>
.demo {
width: 100vw;
height: 100vh;
background: #0a0a0a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3rem;
font-family: system-ui, -apple-system, sans-serif;
color: #f1f5f9;
cursor: none;
}
.heading {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle { font-size: 1rem; color: rgba(148,163,184,0.7); }
.cards {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.card {
padding: 1.5rem 2rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
text-align: center;
}
.card-title { font-size: 1rem; font-weight: 600; color: #e0e7ff; margin-bottom: 0.25rem; }
.card-desc { font-size: 0.8rem; color: rgba(148,163,184,0.6); }
.link {
color: #818cf8;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 8px;
}
</style><script>
import { onMount, onDestroy } from "svelte";
export let dotSize = 8;
export let ringSize = 36;
export let dotColor = "#818cf8";
export let ringColor = "rgba(129, 140, 248, 0.4)";
export let dotSpeed = 0.25;
export let ringSpeed = 0.12;
export let trailSize = 80;
export let trailSpeed = 0.06;
let dotEl, ringEl, trailEl;
let hovering = false;
let visible = false;
let pressing = false;
const mouse = { x: 0, y: 0 };
const dotPos = { x: 0, y: 0 };
const ringPos = { x: 0, y: 0 };
const trailPos = { x: 0, y: 0 };
let animId = 0;
function lerp(a, b, f) {
return a + (b - a) * f;
}
function animate() {
dotPos.x = lerp(dotPos.x, mouse.x, dotSpeed);
dotPos.y = lerp(dotPos.y, mouse.y, dotSpeed);
ringPos.x = lerp(ringPos.x, mouse.x, ringSpeed);
ringPos.y = lerp(ringPos.y, mouse.y, ringSpeed);
trailPos.x = lerp(trailPos.x, mouse.x, trailSpeed);
trailPos.y = lerp(trailPos.y, mouse.y, trailSpeed);
if (dotEl) {
dotEl.style.left = `${dotPos.x}px`;
dotEl.style.top = `${dotPos.y}px`;
}
if (ringEl) {
ringEl.style.left = `${ringPos.x}px`;
ringEl.style.top = `${ringPos.y}px`;
}
if (trailEl) {
trailEl.style.left = `${trailPos.x}px`;
trailEl.style.top = `${trailPos.y}px`;
}
animId = requestAnimationFrame(animate);
}
function handleMove(e) {
mouse.x = e.clientX;
mouse.y = e.clientY;
if (!visible) visible = true;
}
function handleLeave() {
visible = false;
}
function handleEnter() {
visible = true;
}
function handleDown() {
pressing = true;
}
function handleUp() {
pressing = false;
}
function handleOverCapture(e) {
if (e.target.closest('a, button, [role="button"], .hoverable')) hovering = true;
}
function handleOutCapture(e) {
const related = e.relatedTarget;
if (!related || !related.closest('a, button, [role="button"], .hoverable')) hovering = false;
}
onMount(() => {
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
document.addEventListener("mousedown", handleDown);
document.addEventListener("mouseup", handleUp);
document.addEventListener("mouseover", handleOverCapture, true);
document.addEventListener("mouseout", handleOutCapture, true);
animId = requestAnimationFrame(animate);
});
onDestroy(() => {
cancelAnimationFrame(animId);
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseover", handleOverCapture, true);
document.removeEventListener("mouseout", handleOutCapture, true);
});
$: hoverDotSize = hovering ? dotSize * 1.5 : dotSize;
$: hoverRingSize = hovering ? ringSize * 1.6 : ringSize;
</script>
<div style="cursor: none;">
<!-- Cursor dot -->
<div
bind:this={dotEl}
style="
position: fixed;
width: {hoverDotSize}px;
height: {hoverDotSize}px;
background: {hovering ? '#a78bfa' : dotColor};
border-radius: 50%;
pointer-events: none;
z-index: 10001;
transform: translate(-50%, -50%) scale({pressing ? 1.4 : 1});
transition: width 0.2s, height 0.2s, background 0.2s, transform 0.15s;
box-shadow: 0 0 {hovering ? 20 : 10}px {hovering ? 'rgba(167,139,250,0.6)' : dotColor + '80'};
opacity: {visible ? 1 : 0};
"
></div>
<!-- Cursor ring -->
<div
bind:this={ringEl}
style="
position: fixed;
width: {hoverRingSize}px;
height: {hoverRingSize}px;
border: 1.5px solid {hovering ? 'rgba(167,139,250,0.5)' : ringColor};
border-radius: 50%;
pointer-events: none;
z-index: 10000;
transform: translate(-50%, -50%) scale({pressing ? 0.8 : 1});
transition: width 0.3s, height 0.3s, border-color 0.3s, transform 0.15s;
opacity: {visible ? 1 : 0};
"
></div>
<!-- Trail glow -->
<div
bind:this={trailEl}
style="
position: fixed;
width: {trailSize}px;
height: {trailSize}px;
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
opacity: {visible ? 1 : 0};
"
></div>
<!-- Demo content -->
<div class="demo">
<div style="text-align: center;">
<h1 class="heading">Smooth Cursor</h1>
<p class="subtitle">Move your mouse around — notice the smooth lag</p>
</div>
<div class="cards">
{#each ['Hover Me', 'Hover Me Too'] as title}
<div class="card hoverable" style="cursor: none;">
<h3 class="card-title">{title}</h3>
<p class="card-desc">The cursor ring grows on hover</p>
</div>
{/each}
<!-- svelte-ignore a11y-invalid-attribute -->
<a href="#" class="link hoverable" style="cursor: none;" on:click|preventDefault>
Interactive Link
</a>
</div>
</div>
</div>
<style>
.demo {
width: 100vw;
height: 100vh;
background: #0a0a0a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3rem;
font-family: system-ui, -apple-system, sans-serif;
color: #f1f5f9;
cursor: none;
}
.heading {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle { font-size: 1rem; color: rgba(148,163,184,0.7); }
.cards {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.card {
padding: 1.5rem 2rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
text-align: center;
}
.card-title { font-size: 1rem; font-weight: 600; color: #e0e7ff; margin-bottom: 0.25rem; }
.card-desc { font-size: 0.8rem; color: rgba(148,163,184,0.6); }
.link {
color: #818cf8;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 8px;
}
</style>Smooth Cursor
A custom smooth cursor with a dot and ring that gracefully follow the real cursor position with a spring/lerp delay. The default cursor is hidden, replaced by stylized custom elements.
How it works
- The default cursor is hidden via CSS
cursor: none - Two elements — a small dot and a larger ring — track the mouse position
- Linear interpolation (lerp) smooths the movement, with the dot following faster than the ring
requestAnimationFrameupdates positions every frame for buttery-smooth motion
Customization
dotSize/ringSize— dimensions of cursor elementsdotColor/ringColor— colorsdotSpeed/ringSpeed— lerp factor (0-1, higher = faster follow)- Scale/morph on hover over interactive elements
When to use it
- Portfolio and agency websites
- Creative landing pages
- Art and design showcases
- Interactive experiences