Skip to main content

Documentation Index

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

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

IdleGhost watches for quiet moments. After 45 uninterrupted seconds without a mouse movement, keystroke, click, or scroll, a GhostIcon glides in from the right side of the viewport to peer at the idle visitor. Any interaction immediately hides the ghost and resets the countdown. The component is entirely non-interactive — it uses pointer-events-none throughout so it can never accidentally capture the very events it listens for.

Prerequisites

IdleGhost must be rendered inside a HauntProvider. It reads a single value from useHaunt():
ValueTypePurpose
isHauntedbooleanEnables or disables the entire idle-detection system
It also requires Framer Motion (AnimatePresence, motion.div) for the slide-in/out animation.

Idle detection

The component attaches a resetTimer callback to four window events on mount and tears them all down on unmount. When isHaunted becomes false the effect runs its cleanup immediately and forces visible to false:
useEffect(() => {
  if (!isHaunted) { setVisible(false); return; }
  let timer;
  const resetTimer = () => {
    setVisible(false);
    clearTimeout(timer);
    timer = setTimeout(() => setVisible(true), 45000);
  };
  ['mousemove', 'keydown', 'click', 'scroll'].forEach(e =>
    window.addEventListener(e, resetTimer)
  );
  resetTimer(); // start on mount
  return () => {
    clearTimeout(timer);
    ['mousemove', 'keydown', 'click', 'scroll'].forEach(e =>
      window.removeEventListener(e, resetTimer)
    );
  };
}, [isHaunted]);
resetTimer() is also called once when the effect first runs, starting the 45-second countdown immediately on page load rather than waiting for the first event.
Change 45000 (milliseconds) to adjust the idle threshold. For example, 10000 makes the ghost appear after just 10 seconds — useful for testing during development. Remember to restore a sensible value before deploying.

Animation

The ghost slides in and out with a simple motion.div transition handled by AnimatePresence:
StatexopacityDuration
initial (enter from)100 px0
animate (resting position)−20 px12 s, easeInOut
exit (leave to)100 px02 s, easeInOut
The x: -20 resting position nudges the ghost 20 px past the right edge of the visible viewport, so just the body peeks in — the -translate-y-1/2 on the wrapper centres it vertically.
<motion.div
  initial={{ x: 100, opacity: 0 }}
  animate={{ x: -20, opacity: 1 }}
  exit={{ x: 100, opacity: 0 }}
  transition={{ duration: 2, ease: 'easeInOut' }}
  className="fixed top-1/2 right-0 -translate-y-1/2 z-[9997]
             pointer-events-none text-haunt-moon"
>

Visual structure

Inside the motion.div there is a relative container holding the ghost and its pulsing eyes:
<div className="relative">
  <GhostIcon className="w-24 h-24" />
  {/* left eye */}
  <div className="absolute top-8 left-6  w-2 h-2 bg-haunt-bg rounded-full animate-pulse" />
  {/* right eye */}
  <div className="absolute top-8 left-12 w-2 h-2 bg-haunt-bg rounded-full animate-pulse" />
</div>
The ghost body is a 96 × 96 px (w-24 h-24) GhostIcon in the haunt-moon teal colour. Two small dark circles (w-2 h-2 bg-haunt-bg) are positioned absolutely to simulate hollow eyes, each with animate-pulse to give them a slow, eerie throb.

Positioning and z-index

PropertyValueReason
positionfixedAlways relative to the viewport
top / right1/2 / 0Anchored to the vertical centre of the right edge
transform-translate-y-1/2Exact vertical centring
z-index9997Below the cursor dot (9999) and ghost trail (9998)
pointer-eventsnoneNever intercepts clicks or mouse events
pointer-events-none is essential here — without it, the ghost would absorb the mousemove events that resetTimer listens for, and the timer would never be reset by mouse movement over the ghost’s area.

Build docs developers (and LLMs) love