Skip to main content

Documentation Index

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

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

DevHaunt brings its haunted atmosphere to life through two complementary animation layers. CSS keyframe animations handle the ambient, looping effects that run continuously in the background — the drifting fog, flickering windows, swaying sign, and floating ghost bob. Framer Motion takes over for anything that responds to user interaction or navigation: page transitions, entrance reveals, scroll-driven transforms, and staggered list appearances. Understanding which layer does what makes it straightforward to tune, extend, or disable any effect independently.

Animation Stack

LayerTechnologyUsed for
Ambient loopsCSS @keyframesFog drift, window flicker, sign sway, ghost float
Entrance & transitionsFramer Motion motion.*Page blur-in/out, fade-slide reveals, staggered cards
Scroll-reactiveReact useState + useEffectDoor opening perspective transform in HauntedHouse.js
Continuous Framer loopsanimate + repeat: InfinityMoon bob, cloud drift in HauntedHouse.js

Page Transitions

All route-level transitions are handled by AnimatePresence in components/Layout.js. The mode="wait" option ensures the exiting page fully completes its blur-out before the entering page starts its blur-in, preventing two pages from rendering simultaneously.
components/Layout.js
import { AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import { useLocation } from 'react-router-dom';

function Layout({ children }) {
  const location = useLocation();

  return (
    <AnimatePresence mode="wait">
      <motion.main
        key={location.pathname}
        initial={{ opacity: 0, filter: 'blur(10px)' }}
        animate={{ opacity: 1, filter: 'blur(0px)' }}
        exit={{ opacity: 0, filter: 'blur(10px)' }}
        transition={{ duration: 0.8, ease: 'easeInOut' }}
        className="pt-24 pb-32 px-4 md:px-8 max-w-7xl mx-auto min-h-screen
                   flex flex-col items-center justify-center relative z-10"
      >
        {children}
      </motion.main>
    </AnimatePresence>
  );
}
To speed up navigation, lower duration: 0.8 to 0.4. To swap the blur effect for a vertical slide, replace filter: 'blur(10px)' with y: 20 in both initial and exit.

CSS Keyframe Animations

All four CSS animations are defined in assets/main.css and applied via Tailwind utility classes or direct class names on the relevant elements.
Simulates an irregularly flickering light source inside the house windows in HauntedHouse.js. The opacity drops to 0.4 at specific keyframe offsets that mimic a real candle’s unpredictable stutter.
assets/main.css
@keyframes flicker {
  0%, 19.999%, 22%, 62.999%, 64%, 64.999%, 70%, 100% { opacity: 1;   }
  20%, 21.999%, 63%, 63.999%, 65%, 69.999%            { opacity: 0.4; }
}
.animate-flicker {
  animation: flicker 3s linear infinite;
}
Three window groups in HauntedHouse.js use this class with staggered animationDelay values (0s, 1s, 2s) so the windows flicker independently rather than in sync.To slow the flicker: change 3s to 5s. To make it more aggressive: add more opacity-0 keyframe stops.
Applies a gentle pendulum rotation to the DevHaunt wooden sign that hangs below the roofline in the SVG illustration. The rotation pivots around 200px 150px, the top-center of the sign’s rope attachment point.
assets/main.css
@keyframes sway {
  0%, 100% { transform: rotate(-5deg); }
  50%       { transform: rotate(5deg);  }
}
.animate-sway {
  animation: sway 4s ease-in-out infinite;
}
The <g> element in HauntedHouse.js uses both the CSS class and a Framer Motion wrapper:
components/HauntedHouse.js
<motion.g
  className="animate-sway"
  style={{ transformOrigin: '200px 150px' }}
>
  {/* rope lines, sign rect, DevHaunt text */}
</motion.g>
To increase the arc: widen the rotation range from ±5deg to ±10deg. To speed up the pendulum: reduce 4s to 2s.
Powers the FogLayer component that sits fixed at the bottom of the viewport. Two overlapping fog layers animate in opposite directions at different speeds, creating a natural layered fog effect. A mask-image gradient fades the fog out toward the top so it blends seamlessly into the page content.
assets/main.css
@keyframes fog-drift {
  0%   { transform: translate(0);    }
  100% { transform: translate(-50%); }
}

.fog-layer {
  position: absolute;
  bottom: 0;
  width: 200%;           /* doubled so the seamless repeat isn't visible */
  height: 100%;
  background: url('...svg...') repeat-x;
  background-size: 50% 100%;
  animation: fog-drift 40s linear infinite;
}

.fog-layer:nth-child(2) {
  animation: fog-drift 30s linear infinite reverse;
  bottom: -20px;
}
The fog SVG path is inlined as a data: URI so no external image request is made. The fill color is %23f4f1ea (URL-encoded #f4f1ea, the ghost token). Update this hex inside the data URI string to change the fog color.
To slow the fog: increase 40s and 30s. To thicken the fog: increase the SVG path’s opacity attribute from 0.15 / 0.1 to a higher value.
Triggers a vertical bob on elements inside a Tailwind group container when hovered. Used for the floating ghost character in HauntedHouse.js via animate-float-style behavior.
assets/main.css
@keyframes float {
  0%, 100% { transform: translateY(0);     }
  50%       { transform: translateY(-20px); }
}
.group:hover .group-hover\:animate-float {
  animation: float 6s ease-in-out infinite;
}
The moon/ghost element in HauntedHouse.js uses a Framer Motion continuous loop for the same visual effect (see Continuous Float Loop below), while the CSS version activates only on hover within a group.

Framer Motion Patterns

DevHaunt uses Framer Motion consistently across all page components. The following patterns cover every variant present in the codebase.

Entrance Animation — Fade and Slide Up

The most common pattern in the codebase. Used on every page heading, hero section, and intro block. The element starts invisible and 20px below its natural position, then animates to full opacity at its correct location.
<motion.div
  initial={{ y: 20, opacity: 0 }}
  animate={{ y: 0, opacity: 1 }}
  transition={{ delay: 0.2 }}
>
  <h1 className="font-spooky text-5xl text-pumpkin">
    Welcome Foolish Mortals
  </h1>
</motion.div>
A variant used on the About page slides in from the left instead:
<motion.h1
  initial={{ x: -20, opacity: 0 }}
  animate={{ x: 0, opacity: 1 }}
  className="font-spooky text-5xl text-pumpkin"
>
  The Ghost Behind the Screen
</motion.h1>

Staggered List Entrance

Used on the Projects (Graveyard) and Testimonials pages. Each item in the list multiplies index * 0.1 for its delay, so the cards cascade in one after another rather than all appearing at once.
{projects.map((project, index) => (
  <motion.div
    key={project.title}
    initial={{ opacity: 0, y: 50 }}
    animate={{ opacity: 1, y: 0 }}
    transition={{ delay: index * 0.1 }}
  >
    <Tombstone project={project} />
  </motion.div>
))}
To tighten the stagger on long lists, reduce the multiplier from 0.1 to 0.05. To add a spring feel, set transition={{ delay: index * 0.1, type: 'spring', stiffness: 120 }}.

Scale Entrance

Used on the About page’s TrickOrTreatBag illustration. The element begins at 80% scale and fades in, creating a “materializing” effect.
<motion.div
  initial={{ scale: 0.8, opacity: 0 }}
  animate={{ scale: 1, opacity: 1 }}
  transition={{ delay: 0.4 }}
  className="flex-1 w-full"
>
  <TrickOrTreatBag />
</motion.div>
The Skills page uses a spring variant for each pumpkin icon:
<motion.div
  initial={{ opacity: 0, scale: 0.5 }}
  animate={{ opacity: 1, scale: 1 }}
  transition={{ delay: index * 0.1, type: 'spring' }}
>
  <JackOLantern skill={skill} />
</motion.div>

Continuous Float Loop

The moon in HauntedHouse.js bobs continuously using a Framer Motion animate array — no CSS keyframe required. The repeat: Infinity option loops it forever.
components/HauntedHouse.js
<motion.div
  className="absolute top-0 right-10 w-32 h-32 rounded-full bg-[#f4f1ea]"
  animate={{ y: [0, -10, 0] }}
  transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
>
  {/* moon face details */}
</motion.div>
A faster cloud drift uses horizontal translation across the component:
components/HauntedHouse.js
<motion.div
  className="absolute top-10 right-[-50px] w-48 h-12 bg-night/40 rounded-full blur-md"
  animate={{ x: [-100, 300] }}
  transition={{ duration: 25, repeat: Infinity, ease: 'linear' }}
/>

Scroll-Reactive Door Transform

HauntedHouse.js uses a plain React scroll listener (not Framer’s useScroll) to drive a CSS perspective rotation on the front door. As the user scrolls down, the door swings open up to a maximum of 45 degrees.
components/HauntedHouse.js
import { useState, useEffect } from 'react';

function HauntedHouse() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  // 0.5px of rotation per pixel scrolled, clamped to [0, 45]
  const rotation = Math.min(Math.max(scrollY * 0.5, 0), 45);

  return (
    // ...
    <g
      style={{
        transformOrigin: '175px 320px',
        transform: `perspective(400px) rotateY(${rotation}deg)`,
      }}
    >
      {/* door rect, knob, window panel */}
    </g>
  );
}
To make the door open sooner, increase the 0.5 multiplier. To allow a wider swing, raise the 45 cap. To reverse the direction (door opens left), negate the rotation value.

Disabling Animations

Respecting prefers-reduced-motion

Wrapping the app in Framer Motion’s MotionConfig component is the cleanest way to automatically disable all Framer animations for users who have enabled the reduced-motion OS setting. The entry point would look like:
import { MotionConfig } from 'framer-motion';

ReactDOM.render(
  <MotionConfig reducedMotion="user">
    <App />
  </MotionConfig>,
  document.getElementById('root')
);
The "user" value reads the prefers-reduced-motion media query. Animations are only suppressed when the user has explicitly requested it; everyone else sees the full experience.
DevHaunt ships as a pre-built static dist — there is no src/main.jsx or source directory in the repository. Implementing MotionConfig requires access to the original source and a rebuild. The compiled assets/main.js bundles all component code as minified output, making in-place edits impractical for structural changes like this.
MotionConfig reducedMotion="user" disables Framer Motion animations only. The CSS keyframe animations (fog-drift, flicker, sway, float) are not affected. To also suppress those, add a CSS media query at the end of assets/main.css:
assets/main.css
@media (prefers-reduced-motion: reduce) {
  .animate-flicker,
  .animate-sway,
  .fog-layer,
  .group-hover\:animate-float {
    animation: none;
  }
}

Disabling All Animations Globally

To turn off every Framer Motion animation unconditionally (useful for testing or screenshot tools), use reducedMotion="always" in the same MotionConfig wrapper described above:
<MotionConfig reducedMotion="always">
  <App />
</MotionConfig>
As with the reducedMotion="user" approach, this change requires rebuilding from source. For the static dist, the practical alternative is to add the CSS @media (prefers-reduced-motion: reduce) block shown above, which suppresses the CSS keyframe loops without any JavaScript changes.

Build docs developers (and LLMs) love