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.

witch-dev uses Framer Motion as its single animation engine for all React-driven motion — page transitions, entrance sequences, hover physics, and infinite ambient loops. One CSS animation (fog-drift) lives outside Framer Motion because it runs on a purely decorative element that never needs JS-controlled state. Everything else is declarative motion.* props.

Page Transitions

Every route change plays a blur-and-scale transition via AnimatePresence wrapping the router outlet. The key is set to location.pathname so React treats each route as a distinct element and triggers enter/exit animations on navigation.
import { AnimatePresence, motion } from 'framer-motion'
import { useLocation } from 'react-router-dom'

export function AnimatedRoutes({ children }) {
  const location = useLocation()

  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={location.pathname}
        initial={{ opacity: 0, filter: 'blur(10px)', scale: 0.95 }}
        animate={{ opacity: 1, filter: 'blur(0px)',  scale: 1    }}
        exit={{    opacity: 0, filter: 'blur(10px)', scale: 1.05 }}
        transition={{ duration: 0.5, ease: 'easeInOut' }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  )
}
mode="wait" tells AnimatePresence to fully complete the exiting animation before the entering animation begins. Without it, the outgoing page and incoming page would overlap mid-blur — useful for crossfades, but wrong for a sharp scene-change feel. The exit scale goes slightly above 1 (scale: 1.05) rather than below — the page appears to push toward the viewer as it blurs out, reinforcing a sense of depth and forward motion into the next route.
filter: 'blur()' is GPU-composited in modern browsers but still triggers a repaint on every frame in some environments. If frame drops appear on low-powered devices, replace filter: 'blur(10px)' with opacity: 0 alone — the transition remains readable without the blur cost.

Staggered Entrance

The mobile navigation menu staggers its items so they cascade in one by one rather than all appearing at once. Each item reads its own index from the map and adds a proportional delay:
{navItems.map((item, index) => (
  <motion.li
    key={item.path}
    initial={{ opacity: 0, x: -20 }}
    animate={{ opacity: 1, x: 0 }}
    transition={{ delay: index * 0.1 }}
  >
    <NavLink to={item.path}>{item.label}</NavLink>
  </motion.li>
))}
With five items this creates delays of 0s, 0.1s, 0.2s, 0.3s, 0.4s — a tight, readable cascade. Increasing the multiplier (e.g. index * 0.15) slows the stagger; decreasing it toward 0.05 collapses the items into near-simultaneous entry.
For longer lists, cap the maximum delay: delay: Math.min(index * 0.1, 0.4). This prevents the last item in a large list from waiting several seconds before appearing.

Spring Physics

Interactive elements use Framer Motion’s spring transition type for organic, physical-feeling motion. Two distinct spring configurations appear in the codebase:
{skills.map((skill, index) => (
  <motion.button
    key={skill.id}
    initial={{ opacity: 0, scale: 0 }}
    animate={{ opacity: 1, scale: 1 }}
    transition={{
      delay: index * 0.2 + 1,   // 1s base delay + stagger
      type: 'spring',
      stiffness: 50,            // low stiffness = slow, floaty settle
    }}
  >
    {skill.label}
  </motion.button>
))}
stiffness: 50 produces a slow, planetary-weight settle — appropriate for orbital UI elements that should feel large and unhurried. The +1 base delay ensures all orbit buttons appear after the radar polygon beneath them has fully rendered.

Radar Polygon Entrance

The skills radar chart animates its filled polygon in on mount with a scale-up from zero, giving it the feel of a spell diagram materializing:
<motion.polygon
  points={polygonPoints}
  fill="#a3e635"
  stroke="#84cc16"
  initial={{ opacity: 0, scale: 0 }}
  animate={{ opacity: 0.2, scale: 1 }}
  transition={{ duration: 1, ease: 'easeOut' }}
/>
The polygon animates to opacity: 0.2 rather than full opacity — the fill is intentionally translucent so the radar grid lines beneath it remain visible. The easeOut curve decelerates smoothly at full scale, mimicking a shape settling into place.

Infinite Animations

Two ambient background systems run on infinite loops to keep the UI feeling alive at rest.
// Each particle is initialized with randomized path and timing
{particles.map((p) => (
  <motion.div
    key={p.id}
    className="absolute w-1 h-1 rounded-full bg-coven-green-400 mix-blend-screen"
    style={{ left: `${p.startX}vw`, top: '100vh' }}
    animate={{
      y: ['0vh', '-100vh'],
      x: ['0vw', `${p.drift * 20 - 10}vw`],  // drift: random 0–1
      opacity: [0, 0.8, 0],
    }}
    transition={{
      duration: p.duration,   // 10–20 seconds per particle
      repeat: Infinity,
      delay: p.delay,         // 0–5 second randomised start offset
      ease: 'linear',
    }}
  />
))}
Each particle rises from the bottom of the viewport to the top over 10–20 seconds, drifting left or right by up to ±10vw. The opacity keyframes fade the particle in and out over its travel so it never pops on or off. mix-blend-screen makes the green particles glow against coven-black.

Height Animation

The About page timeline uses animated accordion panels. Each entry expands and collapses with a smooth height transition driven by AnimatePresence:
<AnimatePresence>
  {isOpen && (
    <motion.div
      initial={{ opacity: 0, height: 0, y: -10 }}
      animate={{ opacity: 1, height: 'auto', y: 0 }}
      exit={{    opacity: 0, height: 0,      y: -10 }}
      style={{ overflow: 'hidden' }}
    >
      {children}
    </motion.div>
  )}
</AnimatePresence>
height: 'auto' is intentional — the panel content has variable height depending on description length, so a fixed pixel target is impractical. Framer Motion measures the rendered height internally and interpolates to it.
overflow: 'hidden' is required on the animated wrapper. Without it, content that overflows the container during the height tween (when height is between 0 and auto) will be visible outside the panel boundaries during animation.

CSS Animation: Fog Drift

The background fog blobs use a pure CSS animation — no Framer Motion — because they are purely decorative, never need JS-driven state, and can run entirely on the compositor thread without a React render loop:
@keyframes fog-drift {
  0%   { transform: translate(-10%) translateY(0)   scale(1);   opacity: 0.3; }
  50%  { transform: translate(10%)  translateY(-5%) scale(1.1); opacity: 0.5; }
  100% { transform: translate(-10%) translateY(0)   scale(1);   opacity: 0.3; }
}

.animate-fog-drift {
  animation: fog-drift 30s linear infinite;
}
The keyframes oscillate the fog blob horizontally between translate(-10%) and translate(10%) while gently scaling it up and varying its opacity — enough movement to feel organic but slow enough to be subliminal at 30 seconds per cycle. linear timing keeps the drift at a constant pace; the opacity oscillation creates a natural slow pulse. Apply the class in JSX like any Tailwind utility:
<div
  className="absolute w-96 h-96 rounded-full bg-coven-purple-800/20
             blur-3xl mix-blend-multiply animate-fog-drift pointer-events-none"
/>

Performance Notes

All animations in witch-dev are written to stay off the main thread where possible:
1

GPU-accelerated properties only

Animations target opacity, transform (translate, scale, rotate), and filter (blur). These properties are composited on the GPU without triggering layout or paint. Avoid animating width, height (except with Framer Motion’s height-auto feature), top, left, or margin — these force layout recalculation on every frame.
2

pointer-events: none on background layers

Particle containers, fog blobs, and rotating rings all carry pointer-events-none (Tailwind: pointer-events-none). This ensures that the continuously-animating background layers never interfere with click, hover, or focus events on the UI above them.
3

useMemo for the particle array

The particles array is memoized so it is created once on mount rather than regenerated on every render. This prevents particle positions and durations from randomizing on each state update, which would cause all particles to visibly reset.
const particles = useMemo(() =>
  Array.from({ length: 30 }, (_, i) => ({
    id: i,
    startX:   Math.random() * 100,
    drift:    Math.random(),
    duration: 10 + Math.random() * 10,
    delay:    Math.random() * 5,
  })),
[])  // empty deps — compute once, never recompute
Framer Motion’s AnimatePresence and motion.* components add roughly ~34 kB gzipped to the bundle. For a personal portfolio this is well within budget, but if bundle size becomes a concern, framer-motion/dist/framer-motion.esm.js supports tree-shaking — import only the hooks and components you use (motion, AnimatePresence, useAnimation) rather than the full package default export.

Build docs developers (and LLMs) love