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():
| Value | Type | Purpose |
|---|
isHaunted | boolean | Enables 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:
| State | x | opacity | Duration |
|---|
initial (enter from) | 100 px | 0 | — |
animate (resting position) | −20 px | 1 | 2 s, easeInOut |
exit (leave to) | 100 px | 0 | 2 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
| Property | Value | Reason |
|---|
position | fixed | Always relative to the viewport |
top / right | 1/2 / 0 | Anchored to the vertical centre of the right edge |
transform | -translate-y-1/2 | Exact vertical centring |
z-index | 9997 | Below the cursor dot (9999) and ghost trail (9998) |
pointer-events | none | Never 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.