Interactive
CursorTrail
A ribbon follows the pointer, thinning and fading behind it. Draws on one canvas and only while there is a trail to show.
Download
- React
- Tailwind CSS v4
- motion
components/ui/cursor-trail.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useReducedMotion } from "motion/react";
type CursorTrailProps = {
/** The area the trail draws over. */
children: ReactNode;
/** Trail colour. */
color?: string;
/** Width of the trail at the pointer, in pixels. */
width?: number;
/** Seconds a point of the trail lives. */
life?: number;
className?: string;
};
/* CursorTrail: a ribbon follows the pointer, thinning and fading behind it. Draws only while there is a trail to show. */
export function CursorTrail({ children, color = "#a78bfa", width = 10, life = 0.5, className = "" }: CursorTrailProps) {
const reduce = useReducedMotion();
const zone = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const box = zone.current;
const c = canvas.current;
const ctx = c?.getContext("2d");
if (reduce || !box || !c || !ctx) return;
const points: { x: number; y: number; t: number }[] = [];
let frame = 0;
let scale = 1;
const resize = () => {
scale = Math.min(window.devicePixelRatio, 2);
c.width = box.clientWidth * scale;
c.height = box.clientHeight * scale;
};
const observer = new ResizeObserver(resize);
observer.observe(box);
const draw = () => {
const now = performance.now();
const span = life * 1000;
while (points.length && now - points[0].t > span) points.shift();
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, box.clientWidth, box.clientHeight);
ctx.strokeStyle = color;
ctx.lineCap = "butt";
ctx.lineJoin = "round";
// Each piece runs mid-point to mid-point, so the corners stay smooth.
for (let i = 1; i < points.length - 1; i++) {
const [a, b, d] = [points[i - 1], points[i], points[i + 1]];
const fade = 1 - (now - b.t) / span;
ctx.globalAlpha = fade;
ctx.lineWidth = width * fade;
ctx.beginPath();
ctx.moveTo((a.x + b.x) / 2, (a.y + b.y) / 2);
ctx.quadraticCurveTo(b.x, b.y, (b.x + d.x) / 2, (b.y + d.y) / 2);
ctx.stroke();
}
frame = points.length ? requestAnimationFrame(draw) : 0;
};
const onMove = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
points.push({ x: e.clientX - r.left, y: e.clientY - r.top, t: performance.now() });
if (!frame) frame = requestAnimationFrame(draw);
};
box.addEventListener("pointermove", onMove);
return () => {
box.removeEventListener("pointermove", onMove);
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [reduce, color, width, life]);
return (
<div ref={zone} className={`relative ${className}`}>
{children}
<canvas ref={canvas} aria-hidden className="pointer-events-none absolute inset-0 size-full" />
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/cursor-trail.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/cursor-trail-demo.tsx
import { CursorTrail } from "@/components/ui/cursor-trail"; export default function CursorTrailExample() { return ( <CursorTrail className="grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-6 text-center"> <div> <p className="text-2xl font-semibold text-white">Draw with your pointer</p> <p className="mt-1 text-sm text-white/55">The ribbon thins out and fades behind you.</p> </div> </CursorTrail> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | The area the trail draws over. Stays clickable. |
| color | string | "#a78bfa" | Trail colour. |
| width | number | 10 | Width of the trail at the pointer, in pixels. |
| life | number | 0.5 | Seconds a point of the trail lives. |
| className | string | "" | On the area wrapper. |