Skip to main content

Documentation Index

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

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

The Craft is a living grimoire — nothing is ever quite still. Motion is layered into the experience at two levels: a JavaScript animation layer driven by Framer Motion handles interactive and entrance effects that respond to user behavior, while a CSS keyframe layer runs continuously in the background, keeping ambient elements like candle flames, glowing rings, and rotating orbs perpetually in motion. Understanding how both systems work — and where they intersect — gives you full control over the portfolio’s kinetic personality.

Two Animation Systems

Framer Motion

JavaScript-driven. Responds to state, user interaction, and route changes. Handles entrance sequences, hover micro-animations, page transitions, and AnimatePresence-managed content swaps.

CSS Keyframes

Declarative and ambient. Runs continuously in the background regardless of user interaction. Applied via utility classes like .animate-flicker and .animate-spin-slow. Defined entirely in main.css.
Both systems coexist on the same elements — a rotating SVG ring might use .animate-spin-slow for its continuous rotation while Framer Motion handles its entrance opacity fade-in and hover scale response.

CSS Keyframe Animations

Four keyframe animations are defined in assets/main.css and exposed as utility classes. Apply them directly in JSX class names.
ClassKeyframe nameDuration & easingPurpose
.animate-flickerflicker3s, infinite alternateCandle flame opacity and scale oscillation
.animate-pulsepulse2s, cubic-bezier(.4,0,.6,1) infiniteGeneric opacity pulse to 50%
.animate-pulse-glowpulseGlow2s, cubic-bezier(.4,0,.6,1) infiniteTeal drop-shadow breathing between 10px and 4px
.animate-spin-slowspin20s, linear infiniteFull 360° slow rotation for ambient ring elements

Keyframe definitions

/* Candle flame — opacity and subtle scale wobble */
@keyframes flicker {
  0%, 100% { opacity: 1;   transform: scale(1);    }
  25%, 75%  { opacity: 0.9; transform: scale(1.02); }
  50%       { opacity: 0.8; transform: scale(0.98); }
}

/* Generic opacity fade */
@keyframes pulse {
  50% { opacity: 0.5; }
}

/* Teal glow breathing for SVG/icon elements */
@keyframes pulseGlow {
  0%, 100% { opacity: 1;   filter: drop-shadow(0 0 10px #2dd4bf); }
  50%       { opacity: 0.6; filter: drop-shadow(0 0 4px  #14b8a6); }
}

/* Continuous full rotation */
@keyframes spin {
  to { transform: rotate(360deg); }
}

Framer Motion Animation Patterns

Framer Motion is used for every animation that needs to respond to application state, route changes, or user input. The following patterns appear consistently throughout The Craft’s component tree.

Page Entrance — Staggered Reveal

Content sections fade and slide up from below when a page mounts, with each item delayed by its position in the list.
// Individual item entrance — delay staggered by index
<motion.div
  initial={{ opacity: 0, y: 50 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.6, delay: index * 0.2 }}
>
  {/* content */}
</motion.div>
The y: 50 initial offset gives a sense of items rising into place rather than simply fading in. Each additional item waits 0.2s longer, so a list of five items takes one full second to complete its cascade.

Page Transition — Blur Fade via PageTransition

Route changes are handled by a dedicated PageTransition wrapper component (components/PageTransition.js) that blurs and fades the outgoing page out before the incoming page blurs and fades in. The component is itself a motion.divAnimatePresence is applied by the router outlet that wraps it.
// PageTransition component — motion.div with blur-fade values
<motion.div
  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] }}
>
  {children}
</motion.div>
The ease: [0.22, 1, 0.36, 1] cubic-bezier curve produces a fast initial movement that eases into a gradual stop — the “custom ease out” feel that makes the transition feel intentional rather than mechanical. The 0.8s duration gives the blur enough time to resolve cleanly before the user begins reading the new page.

Card Hover — Scale and De-rotate

Project cards enter with a slight rotation applied via an inline style prop (each card’s rotation value from the data array). On hover the card snaps to rotate: 0 alongside a gentle scale-up. Because the rotation is set on the style prop rather than a Framer initial value, the whileHover needs only to reset it explicitly to zero.
<motion.div
  style={{ rotate: cardRotationDegrees }}  // e.g. -2, 3, -1, 4
  whileHover={{ scale: 1.02, rotate: 0, zIndex: 10 }}
  transition={{ duration: 0.6, delay: index * 0.2 }}
>
  {/* project card */}
</motion.div>

Pentagram Node — Scale and Pulse Ring

Navigation nodes on the pentagram diagram scale up on hover and emit an expanding ring that fades out.
// Node scale on hover (uses Framer Motion default spring)
<motion.circle
  whileHover={{ scale: 1.5 }}
/>

// Expanding ring pulse — only shown when node is active
<motion.circle
  initial={{ scale: 0, opacity: 1 }}
  animate={{ scale: 2, opacity: 0 }}
  transition={{ duration: 1, repeat: Infinity }}
/>
The ring starts at the same size as the node (scale: 0 relative to its own enlarged size) and expands to scale: 2 while fading to fully transparent, creating a sonar-like ripple effect.

Ambient Blob — Scale and Opacity Breathing

Large blurred background orbs use a looping scale and opacity animation to give the dark background a sense of slow, living energy.
<motion.div
  animate={{
    scale:   [1, 1.05, 1],
    opacity: [0.5, 0.8, 0.5],
  }}
  transition={{
    duration: 4,
    repeat: Infinity,
    ease: "easeInOut",
  }}
  className="blur-[40px] bg-spell/20 rounded-full"
/>
The array syntax [1, 1.05, 1] defines a three-keyframe sequence — Framer Motion interpolates between these values across the full duration. The blob expands 5 % and brightens to 80 % opacity before contracting back over 4 seconds.

Scrying Mirror — Rotation and Scale Pulse

The decorative scrying mirror element combines a full rotation with a scale pulse over a long 20-second cycle.
<motion.div
  animate={{
    rotate: 360,
    scale:  [1, 1.2, 1],
  }}
  transition={{
    duration: 20,
    repeat: Infinity,
    ease: "linear",
  }}
/>

AnimatePresence mode='wait'

Two distinct UI areas use AnimatePresence with mode="wait" to coordinate animated content swaps. In both cases AnimatePresence is imported from components/CursorTrail.js (which re-exports Framer Motion’s AnimatePresence alongside the cursor particle system).

Testimonials

Testimonial quotes swap in and out with mode="wait" — the exiting quote fully disappears (opacity fade + blur) before the next one enters. The transition values on the inner motion.div use scale rather than y to give a zoom-in/zoom-out feel distinct from the page transition.

Work History Panel

The detail panel for each work history entry uses AnimatePresence mode="wait" so the outgoing role card fully fades and blurs out before the incoming one fades and slides in. The inner motion.div reuses the same blur(10px) + y pattern as PageTransition, keeping motion language consistent.

Reducing Motion

The Craft does not currently include a built-in prefers-reduced-motion guard. Users who have enabled reduced motion in their OS accessibility settings will still see all animations. To respect this preference, add the following check wherever you define Framer Motion variants:
const prefersReducedMotion =
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const variants = prefersReducedMotion
  ? {} // no animation — instant state changes
  : {
      initial: { opacity: 0, y: 20 },
      animate: { opacity: 1, y: 0 },
    };

// Then use the variants prop:
<motion.div variants={variants} initial="initial" animate="animate">
  {/* content */}
</motion.div>
For CSS keyframe animations, add the following block to main.css:
@media (prefers-reduced-motion: reduce) {
  .animate-flicker,
  .animate-pulse,
  .animate-pulse-glow,
  .animate-spin-slow {
    animation: none;
  }
}
PageTransition performance on older mobile devices: The blur-fade route transition uses filter: blur(10px) which requires GPU compositing. On lower-powered devices this can cause dropped frames during navigation. If you experience jank, replace the blur in the PageTransition component with a simpler opacity-only transition:
// Replace blur-fade with opacity-only for better performance
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{    opacity: 0 }}
transition={{ duration: 0.3 }}
This removes the compositing cost while preserving the smooth cross-fade between routes.

Build docs developers (and LLMs) love