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.

Spooky Developer’s animations run on two rails. The first is CSS keyframes — small, repeating atmospheric effects like candle flickers and ghost-eye pulses that tick along at all times. The second is Framer Motion — declarative React animations that handle entrance transitions, scroll reveals, hover interactions, and infinite loops. Together they give the site its sense of restless, haunted life without requiring every component to manage its own animation logic.

CSS Keyframe Animations

Three animation classes are available as Tailwind utilities. Two are defined in main.css; one is Tailwind’s built-in.

animate-flicker — Candle Flame

The custom flicker keyframe simulates the organic irregularity of a real candle flame by cycling opacity across four non-linear stops:
main.css
@keyframes flicker {
  0%,  100% { opacity: 1;   }
  50%        { opacity: 0.8; }
  25%, 75%   { opacity: 0.9; }
}

.animate-flicker {
  animation: flicker 3s infinite alternate;
}
Where it’s used: The two pumpkin candles on the Home page hero — the orange rounded-top elements above the graveyard entrance — both carry animate-flicker. The second candle adds a style={{ animationDelay: '0.5s' }} so the flames flicker out of phase with each other, as real candles would:
{/* First candle */}
<div className="w-8 h-12 bg-haunt-pumpkin/80 rounded-t-full animate-flicker
                shadow-[0_0_20px_rgba(251,146,60,0.6)]" />

{/* Second candle — offset by half a second */}
<div className="w-8 h-12 bg-haunt-pumpkin/80 rounded-t-full animate-flicker
                shadow-[0_0_20px_rgba(251,146,60,0.6)]"
     style={{ animationDelay: '0.5s' }} />

animate-pulse — Ghost Eyes

Tailwind’s built-in animate-pulse drives the ghost eye blink in the IdleGhost component. The ghost appears at the right edge of the screen after 45 seconds of user inactivity, and its two haunt-bg-colored eye dots pulse to suggest blinking:
main.css
@keyframes pulse {
  50% { opacity: 0.5; }
}

.animate-pulse {
  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
IdleGhost.js
<div className="w-2 h-2 bg-haunt-bg rounded-full animate-pulse" />
<div className="w-2 h-2 bg-haunt-bg rounded-full animate-pulse" />

animate-bounce — Standard Tailwind

Tailwind’s built-in animate-bounce is available throughout the project for any element that needs a vertical bounce loop. It uses the standard Tailwind keyframe definition:
main.css
@keyframes bounce {
  0%, 100% {
    transform: translateY(-25%);
    animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
  }
  50% {
    transform: none;
    animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
  }
}

.animate-bounce {
  animation: bounce 1s infinite;
}

Custom 3D Transform Utilities

Three non-standard CSS utilities are defined at the bottom of main.css to support the Projects page door-flip effect:
main.css
.perspective-1000 {
  perspective: 1000px;
}

.preserve-3d {
  transform-style: preserve-3d;
}

.rotate-y-105 {
  transform: rotateY(-105deg);
}
How they work together: Each project card on the Projects page is a two-layer stack — a static back panel (the card content) and a door that swings open on click. The three utilities work as a chain:
  1. perspective-1000 is applied to the outer grid container to establish a 3D perspective context for all child elements.
  2. preserve-3d is applied to each card’s wrapper div so that the door’s rotation is calculated in the same 3D space.
  3. rotate-y-105 is toggled onto the door element when the card is clicked (controlled by React state), swinging it open at −105° like a real door:
Projects page
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8
                max-w-6xl mx-auto perspective-1000">
  {projects.map(project => (
    <div key={project.id} className="relative h-96 w-full preserve-3d">
      {/* Door — rotates on click */}
      <div
        className={`absolute bottom-0 ... origin-left transition-transform
                    duration-700 ease-in-out cursor-pointer z-20
                    ${isOpen ? 'rotate-y-105' : ''}`}
        onClick={() => toggle(project.id)}
      >
        Door {project.id}
      </div>
    </div>
  ))}
</div>
The rotate-y-105 transform uses -105deg rather than -90deg so the door swings slightly past perpendicular, which reads more naturally as a door being pushed fully open.

Framer Motion Patterns

Framer Motion handles all of the app’s interactive and entrance animations. Four patterns appear repeatedly across the codebase.

Pattern 1 — Entrance Fade-In

Used on most pages to bring content in smoothly on mount. Elements start invisible and 20px below their final position, then animate to full opacity at rest:
<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 1 }}
>
  {/* Page content */}
</motion.div>
Where it’s used: The Home page hero wrapper (title + subtitle block), the Contact page ghost popup, and the IdleGhost entrance all use this pattern or a close variant.

Pattern 2 — Scroll-Triggered Reveal

Used on the About (timeline) and Work (experience) pages to reveal each item as it enters the viewport. Items slide in from the left and combine a staggered delay based on their index:
<motion.div
  initial={{ opacity: 0, x: -50 }}
  whileInView={{ opacity: 1, x: 0 }}
  viewport={{ once: true, margin: '-100px' }}
  transition={{ duration: 0.6, delay: index * 0.1 }}
>
  {/* Timeline or work card content */}
</motion.div>
The viewport={{ once: true }} flag means each item animates in exactly once, not every time the user scrolls past it. The margin: '-100px' triggers the animation slightly before the element fully enters the viewport, so users never see a blank flash. The Work page uses a vertical variant (y: 50 instead of x: -50) to slide cards upward:
<motion.div
  initial={{ opacity: 0, y: 50 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true }}
  transition={{ duration: 0.6, delay: index * 0.1 }}
>

Pattern 3 — Hover Lift

Used on the Skills page candy bowl. Each skill “candy” lifts upward, scales up, and jumps to a high z-index when hovered, so it floats above its neighbours:
<motion.div
  initial={{ y: 0 }}
  whileHover={{ y: -50, scale: 1.2, zIndex: 50 }}
  onHoverStart={() => setActive(skill)}
  onHoverEnd={() => setActive(null)}
>
  {/* Candy element */}
</motion.div>
The whileHover state reverts automatically when the cursor leaves — no explicit animate value needed.

Pattern 4 — Infinite Loop (Oscillation)

Used for the rotating site title on the Home page and the ghost drift animation. A rotate array drives the element to oscillate between −2° and +2°, creating a gentle swaying effect:
<motion.span
  className="inline-block"
  animate={{ rotate: [-2, 2, -2] }}
  transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
>
  Spooky Dev
</motion.span>
The Testimonials page uses a linear x loop to drift ghost figures across the full viewport width:
<motion.div
  initial={{ x: -200, y: index * 100 + 50 }}
  animate={{ x: '100vw' }}
  transition={{ duration: 20 + index * 5, repeat: Infinity, ease: 'linear', delay: index * 3 }}
>
  {/* Ghost figure */}
</motion.div>

The proxy.js Module

Throughout the codebase, Framer Motion components are imported as m rather than motion. For example: m.div, m.span, m.button. This comes from assets/proxy.js, which uses the standard Framer Motion lazy-loading pattern — uo(bu) — to wrap the motion factory in a JavaScript Proxy object that creates component variants on first access and caches them:
assets/proxy.js (excerpt)
function uo(t) {
  if (typeof Proxy === 'undefined') return t
  const e = new Map
  const n = (...s) => t(...s)
  return new Proxy(n, {
    get: (s, i) => i === 'create' ? t : (e.has(i) || e.set(i, t(i)), e.get(i))
  })
}
// ...
export { Mu as m }
Each component file then imports m from proxy.js:
import { m } from '../assets/proxy.js'
// ...
<m.div initial={...} animate={...} />
This pattern ensures Framer Motion’s heavy rendering internals are loaded once and shared across the bundle rather than instantiated per-import, keeping the initial JS parse cost low.

Build docs developers (and LLMs) love