Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/apursley2012/dev.void/llms.txt

Use this file to discover all available pages before exploring further.

CometCursor replaces the browser’s default pointer with a custom aurora-teal cursor built entirely from Framer Motion animated <div> elements. It tracks the real mouse position and uses spring physics to make two cursor layers lag behind the pointer by different amounts, producing a fluid comet-like trail. On click, a burst of five small particle dots scatters outward and fades away. The component is designed to be rendered once, at the root of the app, and never intercepts pointer events from underlying UI.

Why the native cursor is hidden

main.css sets cursor: none on the body element globally. This is what suppresses the operating-system arrow cursor across the entire app, allowing CometCursor to act as the sole visible pointer. The custom cursor is rendered at z-index: 100 in a fixed inset-0 pointer-events-none container, so it floats above all content without blocking interactions.
If you remove CometCursor from the component tree, you must also remove or change the cursor: none rule in main.css; otherwise the page will have no visible cursor at all. Find the body block in main.css and change cursor: none to cursor: auto (or delete the line entirely).

Touch device detection

Before rendering anything, the component checks window.matchMedia("(pointer: coarse)").matches. On touch-screen devices the native cursor is already absent, so returning null skips rendering the custom cursor entirely and avoids an invisible fixed layer consuming memory on mobile.

Spring-physics layers

Mouse coordinates are captured by a mousemove listener that calls useMotionValue setters:
const cursorX = useMotionValue(-100);
const cursorY = useMotionValue(-100);
Both values start off-screen at (-100, -100) so the cursor dot does not flash in the top-left corner on first load. Two spring-smoothed values are derived from the raw motion values:
const springConfig = { damping: 25, stiffness: 300, mass: 0.5 };
const springX = useSpring(cursorX, springConfig);
const springY = useSpring(cursorY, springConfig);
The same spring config drives both cursor layers, but the visual lag feels different because the dot and the ring have different physical sizes — the ring’s larger inertia makes it appear to trail more noticeably.
Increasing damping makes the cursor feel heavier and slower to settle. Increasing stiffness makes it snap to the pointer more quickly. The default values (damping: 25, stiffness: 300, mass: 0.5) are tuned to feel responsive on the dot while giving the ring a slight elastic drag.

Cursor dot

A small w-4 h-4 (1 rem) circle in bg-aurora-light with mix-blend-screen and a cyan glow (shadow-[0_0_15px_rgba(6,182,212,0.8)]). mix-blend-screen causes it to brighten the pixels beneath it rather than cover them, keeping text legible when the dot passes over it. On mousedown, the dot scales down to 0.8; on mouseup it returns to 1. This gives tactile feedback without any additional state management:
// scale prop on the dot
scale: isPressed ? 0.8 : 1

Trailing ring

A larger w-12 h-12 (3 rem) hollow circle with border border-aurora-teal/40. It follows the same spring coordinates as the dot. On mousedown it scales up to 1.5 and fades to opacity: 0, and on mouseup it returns to its default size and opacity. This “expand and vanish” effect reinforces the click action visually.

Click-burst particles

mousedown events push five new particle objects into state. Each particle is assigned:
  • id: Date.now() + index for a stable React key.
  • x / y: the exact clientX / clientY of the click.
The particle array is capped at 20 entries (slice(-20)) to prevent unbounded growth during rapid clicking. A useEffect watches the particle array and removes the oldest five entries after 500 ms, matching the animation duration. Each particle animates from the click position outward by a random offset of ±25 px in both axes, shrinking from scale: 1 to scale: 0 and opacity: 0 over 500 ms with an easeOut curve:
<motion.div
  className="absolute w-1 h-1 bg-aurora-mint rounded-full"
  initial={{ x: particle.x, y: particle.y, opacity: 1, scale: 1 }}
  animate={{
    x: particle.x + (Math.random() - 0.5) * 50,
    y: particle.y + (Math.random() - 0.5) * 50,
    opacity: 0,
    scale: 0,
  }}
  transition={{ duration: 0.5, ease: "easeOut" }}
  key={particle.id}
/>
The random offsets are evaluated once when the particle is created, not on every re-render, so each particle follows a consistent path even if the component re-renders during the animation.

Full component structure

// Simplified render output
<div className="pointer-events-none fixed inset-0 z-[100]">

  {/* Cursor dot — snaps to pointer with spring lag */}
  <motion.div
    className="absolute w-4 h-4 bg-aurora-light rounded-full
               mix-blend-screen shadow-[0_0_15px_rgba(6,182,212,0.8)]"
    style={{ x: springX, y: springY, translateX: "-50%", translateY: "-50%" }}
    animate={{ scale: isPressed ? 0.8 : 1 }}
  />

  {/* Trailing ring — same spring, different visual weight */}
  <motion.div
    className="absolute w-12 h-12 border border-aurora-teal/40 rounded-full"
    style={{ x: springX, y: springY, translateX: "-50%", translateY: "-50%" }}
    animate={{ scale: isPressed ? 1.5 : 1, opacity: isPressed ? 0 : 1 }}
    transition={{ duration: 0.2 }}
  />

  {/* Click-burst particles */}
  {particles.map((p) => (
    <motion.div
      key={p.id}
      className="absolute w-1 h-1 bg-aurora-mint rounded-full"
      initial={{ x: p.x, y: p.y, opacity: 1, scale: 1 }}
      animate={{ x: p.x + offsetX, y: p.y + offsetY, opacity: 0, scale: 0 }}
      transition={{ duration: 0.5, ease: "easeOut" }}
    />
  ))}

</div>

Usage

Place <CometCursor /> once, directly inside your root layout wrapper, alongside StarfieldBackground and AuroraNav:
import { CometCursor } from "./components/CometCursor";

function App() {
  return (
    <HashRouter>
      <div className="relative min-h-screen bg-space-950 text-slate-200">
        <StarfieldBackground />
        <CometCursor />
        <AuroraNav />
        <main className="relative z-10">
          {/* page content */}
        </main>
      </div>
    </HashRouter>
  );
}

Customisation

Spring feel

Adjust the spring config to change how closely the cursor tracks the pointer:
const springConfig = {
  damping: 25,    // ↑ = heavier / slower
  stiffness: 300, // ↑ = snappier
  mass: 0.5,      // ↑ = more inertia
};

Dot colour

The dot uses bg-aurora-light (#ccfbf1) and a cyan glow. Replace both the Tailwind class and the box-shadow rgba value to recolour the cursor for a different theme.

Particle count

Change Array.from({ length: 5 }) to emit more or fewer particles per click. Remember to adjust the slice(-20) cap and the slice(5) cleanup step proportionally.

Disabling on all devices

Remove <CometCursor /> from the tree and change cursor: nonecursor: auto in main.css. No other files need to be modified.

Build docs developers (and LLMs) love