Skip to main content

Documentation Index

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

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

CursorTrail replaces the default browser cursor with a custom two-part effect: a glowing acid-green circle that follows the pointer with a slight easing lag, and a fading trail of up to 15 purple dots that evaporate behind it. The entire component is non-interactive (pointer-events: none) and renders at z-[100], placing it above every other element in the stacking context including the mobile menu overlay.

Cursor element

The primary cursor is a 16 × 16px circle rendered as a Framer Motion motion.div:
<motion.div
  className="absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-coven-green-400 mix-blend-screen blur-[2px]"
  animate={{ x: cursor.x, y: cursor.y }}
  transition={{ type: 'tween', ease: 'backOut', duration: 0.1 }}
  style={{ boxShadow: '0 0 15px 5px rgba(163, 230, 53, 0.6)' }}
/>
Key styling decisions:
  • bg-coven-green-400 — solid acid-green fill that pops against the near-black portfolio background.
  • mix-blend-screen — the cursor brightens whatever is beneath it rather than covering it, so text and UI elements remain readable through the glow.
  • blur-[2px] — softens the hard circle edge into a diffuse light source.
  • boxShadow: '0 0 15px 5px rgba(163, 230, 53, 0.6)' — extends the glow effect well beyond the element’s own 16px boundary, creating a halo radius of roughly 20px on each side.

Easing

type: 'tween', ease: 'backOut', duration: 0.1 gives the cursor a slight overshoot (“backOut”) as it settles on the target position. This tiny elastic snap adds a magical, alive quality to cursor movement without feeling sluggish.

Trail mechanism

Mouse position is tracked with a mousemove event listener mounted in a useEffect. On each event, two pieces of state update:
  1. cursor — the current {x, y} position used by the main cursor element.
  2. trail — a rolling array of the most recent positions, capped at 15 entries.
const [cursor, setCursor] = useState({ x: 0, y: 0 });
const [trail, setTrail]   = useState([]);

useEffect(() => {
  let id = 0;
  const onMove = (e) => {
    setCursor({ x: e.clientX, y: e.clientY });
    setTrail(prev => {
      const next = [...prev, { x: e.clientX, y: e.clientY, id: id++ }];
      if (next.length > 15) next.shift(); // drop oldest when over cap
      return next;
    });
  };
  window.addEventListener('mousemove', onMove);
  return () => window.removeEventListener('mousemove', onMove);
}, []);
A monotonically increasing id counter (closed over in the useEffect) provides a stable React key for each trail dot without relying on array index, which prevents Framer Motion from reusing animation state across positions.

Prune interval

A separate setInterval ticks every 50ms to remove the oldest dot from the trail array. This ensures trail dots disappear even when the mouse is stationary — they don’t linger indefinitely once the pointer stops moving.
useEffect(() => {
  const t = setInterval(
    () => setTrail(p => p.length > 0 ? p.slice(1) : p),
    50
  );
  return () => clearInterval(t); // cleanup on unmount
}, []);
The cleanup () => clearInterval(t) is critical — without it, the interval would continue running after the component unmounts, updating state on an unmounted component.

Trail dot styling

Each dot in the trail array renders as an 8 × 8px purple circle:
{trail.map((dot, i) => (
  <motion.div
    key={dot.id}
    className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-coven-purple-400 mix-blend-screen"
    initial={{ opacity: 0.8, scale: 1, x: dot.x, y: dot.y }}
    animate={{ opacity: 0, scale: 0 }}
    transition={{ duration: 0.5 }}
    style={{ boxShadow: '0 0 10px 2px rgba(192, 38, 211, 0.4)' }}
  />
))}
  • initial — each dot spawns at full opacity and full scale at the exact mouse position captured at birth (dot.x, dot.y). It does not follow the cursor after spawning.
  • animate — Framer Motion immediately begins transitioning the dot to opacity: 0, scale: 0 over 500ms. By the time a dot reaches 0 it is effectively invisible and zero-sized.
  • bg-coven-purple-400 with a magenta-purple boxShadow — the purple color contrast against the green cursor makes the trail read as a comet tail, reinforcing the directional movement cue.
  • mix-blend-screen — matches the cursor blend mode so both elements interact with background content consistently.

Z-index

The component’s root element sits at z-[100]:
<div className="pointer-events-none fixed inset-0 z-[100] overflow-hidden">
  {/* cursor + trail dots */}
</div>
With z-[100] the cursor trail renders above:
ElementZ-index
Background effectsz-0
Page contentz-10 (typical)
Desktop navigation sidebarz-50
Mobile menu overlayz-[60]
Cursor trailz-[100]
This guarantees the cursor is always visible, even when the mobile overlay is open.

pointer-events: none

The entire component is wrapped in pointer-events-none. Without this, the fixed inset-0 container would block all mouse events from reaching page content — links, buttons, and interactive elements would become unclickable. The custom cursor is purely cosmetic.
Never remove pointer-events-none from the root container. The component fills the entire viewport (fixed inset-0) and will silently swallow all click and hover events on the page if that class is absent.

Performance tip

The combination of a max-15 trail cap and the 50ms prune interval keeps the live DOM node count bounded at a maximum of 16 motion.div elements (1 cursor + 15 trail dots) at any moment. This matters because each motion.div maintains its own Framer Motion animation subscription. Framer Motion drives the opacity and scale exit animations via the WAAPI (Web Animations API) where supported, offloading work to the compositor thread and keeping the main thread available for React rendering. The useMemo-free trail is intentional: trail positions must be fresh on every mouse event, so memoization would provide no benefit here.

Build docs developers (and LLMs) love