A soft ribbon of light behind the pointer, across the window or inside one section.
Customize
Usage
import { CursorTrail } from "@/components/motionkit/cursor-trail";
// across the whole window, e.g. once in your layout
<CursorTrail />
// or inside one section: the parent needs position: relative
<section className="relative">
<CursorTrail contained />
…
</section>Motion
The exact curves this component runs, plotted with Motion's own maths.
Fade
Pointer moves
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/cursor-trail.tsx.
"use client";
import { useEffect, useRef } from "react";
type CursorTrailProps = {
/** Trail colour as a hex value. */
color?: string;
/** Milliseconds each point of the trail stays visible. */
lifetime?: number;
/** Line width in pixels at the freshest point. */
width?: number;
/** Opacity at the freshest point, 0 to 1. */
opacity?: number;
/**
* Draw only inside the parent element (which must be positioned) instead of across the
* whole window. A contained trail takes over from a window trail while the pointer is inside it.
*/
contained?: boolean;
className?: string;
};
function toRgb(hex: string) {
const value = hex.replace("#", "");
const full = value.length === 3 ? [...value].map((c) => c + c).join("") : value;
const number = parseInt(full, 16);
return `${(number >> 16) & 255} ${(number >> 8) & 255} ${number & 255}`;
}
/**
* A soft trail behind the pointer. Mouse and trackpad only, off for reduced motion, and it
* stops drawing once the pointer rests, so it costs nothing when idle.
*/
export function CursorTrail({
color = "#5FAEB4",
lifetime = 450,
width = 6,
opacity = 0.35,
contained = false,
className = "",
}: CursorTrailProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
const area = contained ? canvas?.parentElement : window;
if (!canvas || !context || !area) return;
if (!window.matchMedia("(pointer: fine)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const rgb = toRgb(color);
const points: { x: number; y: number; time: number }[] = [];
let frame = 0;
let size = { width: 0, height: 0 };
function resize() {
const ratio = window.devicePixelRatio || 1;
const rect = canvas!.getBoundingClientRect();
size = { width: rect.width, height: rect.height };
canvas!.width = rect.width * ratio;
canvas!.height = rect.height * ratio;
context!.setTransform(ratio, 0, 0, ratio, 0, 0);
}
function draw() {
const now = performance.now();
while (points.length && now - points[0].time > lifetime) points.shift();
context!.clearRect(0, 0, size.width, size.height);
context!.lineCap = "round";
for (let i = 1; i < points.length; i++) {
const life = 1 - (now - points[i].time) / lifetime;
context!.strokeStyle = `rgb(${rgb} / ${life * opacity})`;
context!.lineWidth = 1 + life * (width - 1);
context!.beginPath();
context!.moveTo(points[i - 1].x, points[i - 1].y);
context!.lineTo(points[i].x, points[i].y);
context!.stroke();
}
frame = points.length ? requestAnimationFrame(draw) : 0;
}
function onMove(event: PointerEvent) {
if (event.pointerType !== "mouse") return;
// a contained trail underneath the pointer has the stage
if (!contained && (event.target as Element | null)?.closest?.("[data-cursor-trail]")) return;
const rect = canvas!.getBoundingClientRect();
points.push({ x: event.clientX - rect.left, y: event.clientY - rect.top, time: performance.now() });
if (!frame) frame = requestAnimationFrame(draw);
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
area.addEventListener("pointermove", onMove as EventListener, { passive: true });
if (contained) canvas.parentElement!.dataset.cursorTrail = "";
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
area.removeEventListener("pointermove", onMove as EventListener);
if (contained) delete canvas.parentElement?.dataset.cursorTrail;
};
}, [color, lifetime, width, opacity, contained]);
return (
<canvas
ref={canvasRef}
aria-hidden
className={`pointer-events-none ${contained ? "absolute" : "fixed z-30"} inset-0 size-full ${className}`}
/>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| color | string | "#5FAEB4" | Trail colour as a hex value. |
| lifetime | number | 450 | Milliseconds each point stays visible. |
| width | number | 6 | Line width at the freshest point, in pixels. |
| opacity | number | 0.35 | Opacity at the freshest point. |
| contained | boolean | false | Draw only inside the (positioned) parent instead of the whole window. |
Ambient by default
Aurora Background
Drifting, blurred color fields for hero sections and empty states.
Signal detected
Flicker Grid
A field of tiny squares that flicker softly, drawn on a canvas so it stays light.
Northern lights
Aurora Mesh
Fields of violet, blue and cyan light drift and blend under swaying aurora ribbons.