Skip to main content

Documentation Index

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

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

Spell Index’s animation layer is built entirely on Framer Motion for React-driven transitions and a single custom CSS keyframe for the decorative reverse-spin element. Every animated element uses motion.div (or motion.p, motion.h1, etc.) from Framer Motion — the library is aliased as a in the compiled bundle, so you’ll see a.div, a.p, and so on throughout the source. Animations are intentionally staggered and eased to feel deliberate rather than snappy, matching the slow-burn atmosphere of the portfolio theme.

Core Patterns

1. Fade-in from below (entrance animation)

The most frequently used pattern — elements start invisible and slightly below their final position, then rise into view. Used on page headings, hero sections, and the contact form wrapper:
import { motion } from 'framer-motion'

<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 1, delay: 0.2 }}
>
  {/* content */}
</motion.div>
A y: -20 variant (dropping down instead of rising) is used for page titles:
<motion.div
  initial={{ opacity: 0, y: -20 }}
  animate={{ opacity: 1, y: 0 }}
  className="text-center mb-16"
>
  <h1 className="font-cinzel text-4xl md:text-5xl text-amber mb-4">
    Origin Story
  </h1>
  <p className="text-teal/60 font-inter tracking-widest uppercase text-sm">
    Draw your cards to reveal the past
  </p>
</motion.div>

2. Scroll-triggered with whileInView

The Case Studies page uses whileInView to animate timeline entries only when they scroll into the viewport. This is the canonical scroll-triggered pattern across the app:
<motion.div
  initial={{ opacity: 0, x: -20 }}
  whileInView={{ opacity: 1, x: 0 }}
  viewport={{ once: true, margin: '-100px' }}
  transition={{ duration: 0.6 }}
  className="relative pl-20 md:pl-24"
>
  {/* case study card */}
</motion.div>
Always set viewport={{ once: true }} for scroll-triggered animations unless you specifically want them to replay every time the element re-enters the viewport. Re-playing entrance animations on scroll-back looks jittery and breaks the incantation feel of the portfolio.
The margin: '-100px' offset triggers the animation slightly before the element fully enters the viewport, so content begins animating as the user approaches it rather than when it’s fully visible.

3. Staggered children with delay

The TarotCard and project grid components stagger each child by passing an incremental delay prop, creating a cascading reveal effect:
// Parent maps over items and passes an increasing delay
{projects.map((project, index) => (
  <SpellCircleCard
    key={project.title}
    {...project}
    delay={index * 0.2}   // 0s, 0.2s, 0.4s, 0.6s …
  />
))}
Inside each card component, the delay prop is wired to the transition:
// Inside SpellCircleCard / TarotCard
const MyCard = ({ delay }) => (
  <motion.div
    initial={{ opacity: 0, y: 20 }}
    animate={{ opacity: 1, y: 0 }}
    transition={{ duration: 0.8, delay }}
  >
    {/* card content */}
  </motion.div>
)
The CandleTimeline uses delay: index * 0.3 for a slightly slower stagger:
<motion.div
  initial={{ opacity: 0, y: 50 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true }}
  transition={{ duration: 0.8, delay: index * 0.3 }}
>
  {/* candle column */}
</motion.div>

4. Scale entrance

The Skills page wraps the SpellBook in a scale entrance — the book grows from 90% to full size as it fades in, giving it a conjuring-from-thin-air feel:
<motion.div
  initial={{ opacity: 0, scale: 0.9 }}
  animate={{ opacity: 1, scale: 1 }}
  transition={{ duration: 0.8 }}
>
  <SpellBook />
</motion.div>
The Contact page uses the same pattern with a short delay:
<motion.div
  initial={{ opacity: 0, scale: 0.9 }}
  animate={{ opacity: 1, scale: 1 }}
  transition={{ delay: 0.2, duration: 0.8 }}
>
  <SummoningForm />
</motion.div>

5. Spring-based 3D page flip (SpellBook)

The SpellBook component uses a direction-aware variant map to produce a 3D page-turn effect. The direction value is either 1 (next page) or -1 (previous page), and it controls which way the Y-axis rotation travels:
const variants = {
  enter: (direction) => ({
    rotateY: direction > 0 ? -90 : 90,
    opacity: 0,
    z: 100,
  }),
  center: {
    rotateY: 0,
    opacity: 1,
    z: 0,
  },
  exit: (direction) => ({
    rotateY: direction > 0 ? 90 : -90,
    opacity: 0,
    z: 100,
  }),
}
The motion.div receives the current direction value via the custom prop, and the parent AnimatePresence also receives custom:
<AnimatePresence mode="wait" custom={direction}>
  <motion.div
    key={currentPage}
    custom={direction}
    variants={variants}
    initial="enter"
    animate="center"
    exit="exit"
    transition={{ duration: 0.6, type: 'spring', bounce: 0.2 }}
    style={{ transformStyle: 'preserve-3d' }}
    className="flex-1 flex flex-col origin-left"
  >
    {/* page content */}
  </motion.div>
</AnimatePresence>
The type: 'spring' transition with bounce: 0.2 gives the page a subtle elastic settle at the end of each flip, as if the paper is genuinely resisting being turned.
When using AnimatePresence with variants, the key prop on the motion.div child is what tells Framer Motion that the element has changed and an exit/enter cycle should run. Without a changing key, AnimatePresence will never trigger the exit animation.

6. Looping candle flame

The CandleTimeline renders a motion.div that loops forever, cycling through scale, rotation, and opacity values to simulate a flickering flame:
<motion.div
  className="absolute bottom-0 w-4 h-8 bg-amber rounded-[50%_50%_20%_20%] blur-[2px] mix-blend-screen"
  animate={{
    scale:   [1, 1.1, 0.9, 1.05, 1],
    rotate:  [0, -2, 3, -1, 0],
    opacity: [0.8, 1, 0.7, 0.9, 0.8],
  }}
  transition={{
    duration: 0.5,
    repeat: Infinity,
    ease: 'easeInOut',
  }}
/>
Each value array is a keyframe sequence. Framer Motion steps through the values over the duration and then loops. The second flame element — a blurred glow halo — pulses more slowly:
<motion.div
  className="absolute bottom-0 w-16 h-16 bg-amber/20 rounded-full blur-xl pointer-events-none"
  animate={{ opacity: [0.3, 0.5, 0.3] }}
  transition={{ duration: 2, repeat: Infinity }}
/>

PageTransition Component

Every route in the app is wrapped in a <PageTransition> component. It applies a blur-fade that makes pages dissolve in and out rather than cutting hard:
// components/witchy/PageTransition.js
import { motion } from 'framer-motion'
import { useLocation } from 'react-router-dom'

const PageTransition = ({ children }) => {
  const location = useLocation()

  return (
    <motion.div
      key={location.pathname}
      initial={{ opacity: 0, filter: 'blur(10px)', y: 20 }}
      animate={{ opacity: 1, filter: 'blur(0px)', y: 0 }}
      exit={{ opacity: 0, filter: 'blur(10px)', y: -20 }}
      transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
      className="min-h-screen flex flex-col"
    >
      {children}
    </motion.div>
  )
}
The key={location.pathname} is what triggers a fresh animation cycle on every route change — Framer Motion treats a new key as a newly mounted component.

Wrapping a route

Every route element in main.js is wrapped with <PageTransition>:
<Route path="/about"    element={<PageTransition><About /></PageTransition>} />
<Route path="/projects" element={<PageTransition><Projects /></PageTransition>} />

AnimatePresence mode="wait"

The router-level AnimatePresence wraps the Routes component with mode="wait", which ensures the exiting page fully completes its blur-out before the entering page starts blurring in:
import { AnimatePresence } from 'framer-motion'
import { Routes, Route, useLocation } from 'react-router-dom'

const AppRouter = () => {
  const location = useLocation()

  return (
    <AnimatePresence mode="wait">
      <Routes location={location} key={location.pathname}>
        <Route path="/"    element={<PageTransition><Home /></PageTransition>} />
        <Route path="/about" element={<PageTransition><About /></PageTransition>} />
        {/* … */}
      </Routes>
    </AnimatePresence>
  )
}
AnimatePresence requires its direct children to have a key prop that changes when the content changes. The key={location.pathname} on Routes (or on the motion.div inside PageTransition) fulfills this requirement. Omitting it causes exit animations to never fire.
The easing curve [0.22, 1, 0.36, 1] is a cubic-bezier approximation of an “ease-out expo” — fast initial movement that decelerates smoothly to a rest. This matches the feeling of a heavy tome falling open.

CSS Animation: animate-spin-reverse-slow

A single custom CSS keyframe is defined outside Tailwind’s animation system. It powers the decorative circular ornament (seen in the hero section’s MoonPhases component):
/* Defined in main.css */
@keyframes spin-reverse {
  0%   { transform: rotate(360deg); }
  to   { transform: rotate(0deg); }
}

.animate-spin-reverse-slow {
  animation: spin-reverse 20s linear infinite;
}
The keyframe runs from 360deg to 0deg, producing a counter-clockwise rotation. The 20-second duration keeps it barely perceptible — a slow orbital drift rather than an obvious spin.
<div className="animate-spin-reverse-slow">
  {/* decorative circle or ring SVG */}
</div>
Because this animation is defined as a pure CSS class rather than a Framer Motion variant, you can combine it with Framer Motion entrance animations on the same element — just apply both the class and the motion.* props. CSS animation and Framer Motion transform operate on different properties and do not conflict.

Animation Quick Reference

PatternProps UsedTypical DurationComponent
Fade-up entranceinitial/animate1sAll page headings
Drop-down entranceinitial/animatedefaultSection headers
Scroll-triggeredwhileInView, viewport0.6sCase studies timeline
Staggered childrendelay={index * 0.2}0.8sTarotCard, project grid
Scale entranceinitial/animate w/ scale0.8sSpellBook, contact form
3D page flipcustom, variants0.6s springSpellBook
Candle flame loopanimate keyframe array0.5s ∞CandleTimeline
Page blur-fadePageTransition wrapper0.8sEvery route
Reverse orbital.animate-spin-reverse-slow20s ∞Hero ornament

Build docs developers (and LLMs) love