Skip to main content

Documentation Index

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

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

Nightshade runs two animation systems side by side. CSS keyframe animations handle the candle flame flicker — a tight, looping micro-animation that does not need JavaScript — while Framer Motion orchestrates every higher-level interaction: page fades, scroll-triggered reveals, the spring-physics cursor, and the shared-layout flame that glides between navigation items on route change.

CSS Keyframe Animations

Candle Flicker

The flicker keyframe animation is defined in assets/main.css and drives all candle flame elements. It combines subtle scale changes on both axes with slight opacity and brightness pulses to simulate an organic, unsteady flame — no single property dominates, so the result reads as natural rather than mechanical.
@keyframes flicker {
  0%   { transform: scaleY(1)    scaleX(1);    opacity: 0.9;  filter: brightness(1);   }
  25%  { transform: scaleY(1.05) scaleX(0.95); opacity: 1;    filter: brightness(1.2); }
  50%  { transform: scaleY(0.95) scaleX(1.02); opacity: 0.8;  filter: brightness(0.9); }
  75%  { transform: scaleY(1.02) scaleX(0.98); opacity: 0.95; filter: brightness(1.1); }
  100% { transform: scaleY(1)    scaleX(1);    opacity: 0.9;  filter: brightness(1);   }
}

.animate-flicker {
  animation: flicker 2s infinite alternate ease-in-out;
  transform-origin: bottom center;
}
transform-origin: bottom center anchors the scale transform to the base of the flame shape, so the tip sways while the wick point stays fixed — the same physics as a real flame.

Desynchronized Delay Classes

Four delay utility classes apply different animation-delay and animation-duration values to individual candles so they flicker out of phase with each other. Without desynchronization, multiple candles on the same page would pulse in unison, which reads as artificial.
.flicker-delay-1 { animation-delay: 0.1s; animation-duration: 2.1s; }
.flicker-delay-2 { animation-delay: 0.4s; animation-duration: 1.9s; }
.flicker-delay-3 { animation-delay: 0.7s; animation-duration: 2.2s; }
.flicker-delay-4 { animation-delay: 0.2s; animation-duration: 1.8s; }
Combine a delay class with .animate-flicker on each flame element:
<div className="animate-flicker flicker-delay-2">
  {/* flame SVG or div */}
</div>

Framer Motion Patterns

Page Transition

The top-level motion.main wrapper in each route component uses opacity combined with a brightness filter to fade the entire page in and out. The brightness drop on exit makes the page feel like a candle being snuffed rather than a flat opacity fade.
<motion.main
  initial={{ opacity: 0, filter: "brightness(0.5)" }}
  animate={{ opacity: 1, filter: "brightness(1)" }}
  exit={{ opacity: 0, filter: "brightness(0)" }}
  transition={{ duration: 0.8, ease: "easeInOut" }}
>
  {children}
</motion.main>
The exit prop requires the component to be wrapped in Framer Motion’s <AnimatePresence> at the router level. Removing <AnimatePresence> disables all exit animations without affecting the enter animation.

Scroll-Triggered Reveals

Timeline entries in the About page and project cards in the Work section use whileInView to trigger a slide-and-fade when the element scrolls into the viewport. The viewport: { once: true } option fires the animation exactly once per page load, preventing it from replaying when the user scrolls back up.
/* Vertical reveal — About timeline entries */
<motion.div
  initial={{ opacity: 0, y: 50 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: "-100px" }}
  transition={{ duration: 0.6, ease: "easeOut" }}
/>

/* Horizontal reveal — Work card detail lines */
<motion.span
  initial={{ opacity: 0, x: -20 }}
  whileInView={{ opacity: 1, x: 0 }}
  viewport={{ once: true, margin: "-100px" }}
  transition={{ duration: 0.4, ease: "easeOut" }}
/>
The margin: "-100px" offset fires the animation when the element is 100 pixels inside the visible area rather than exactly at the viewport edge, giving a slightly early reveal that feels natural when scrolling at moderate speed.

Shared Layout Animation — Navigation Flame

The active-page flame indicator in the navigation is a single motion.div that carries a layoutId. When the user navigates to a different page, Framer Motion detects that the layoutId="activeFlame" element has moved to a different nav item and automatically interpolates its position, size, and shape between the two locations — no manual position calculation required.
{isActive && (
  <motion.div
    layoutId="activeFlame"
    className="absolute inset-0"
    transition={{ type: "spring", stiffness: 400, damping: 30 }}
  />
)}
Only one flame element renders in the DOM at any time. The spring transition gives the repositioning a physical snap rather than a linear slide.

Spring-Physics Cursor — FamiliarCursor

The custom cursor component (FamiliarCursor) uses Framer Motion’s useSpring to attach the cursor visuals to the true pointer position with a physical lag. The spring constants are tuned so the cursor trails with a quick but perceptible delay — evoking a small familiar hovering near your hand.
import { useMotionValue, useSpring } from "framer-motion";

const rawX = useMotionValue(0);
const rawY = useMotionValue(0);

const x = useSpring(rawX, { damping: 25, stiffness: 150, mass: 0.5 });
const y = useSpring(rawY, { damping: 25, stiffness: 150, mass: 0.5 });
  • damping: 25 — moderately resistive; the cursor decelerates smoothly without oscillating.
  • stiffness: 150 — medium pull toward the true pointer; a higher value would make the trail tighter.
  • mass: 0.5 — lighter than the default 1, so the cursor feels nimble rather than heavy.

SmokeLayer Blob Loop

The ambient smoke background (SmokeLayer) animates three large blurred blobs in an infinite breathing loop. Each blob drifts across a small percentage range on both axes while scaling gently, creating the impression of slow-moving smoke without any turbulence simulation. An SVG <feTurbulence> displacement filter is applied to each blob to add organic warping on top of the motion keyframes.
{/* Blob 1 — witch-teal, bottom-left, 25 s */}
<motion.div
  className="... bg-witch-teal/20 blur-[100px]"
  animate={{
    x:     ["0%", "20%",  "0%"],
    y:     ["0%", "-10%", "0%"],
    scale: [1,    1.2,    1],
  }}
  transition={{ duration: 25, repeat: Infinity, ease: "easeInOut" }}
/>

{/* Blob 2 — witch-plum, top-right, 30 s, delay 5 s */}
<motion.div
  className="... bg-witch-plum/20 blur-[120px]"
  animate={{
    x:     ["0%", "-30%", "0%"],
    y:     ["0%", "20%",  "0%"],
    scale: [1,    1.5,    1],
  }}
  transition={{ duration: 30, repeat: Infinity, ease: "easeInOut", delay: 5 }}
/>

{/* Blob 3 — witch-turquoise, center, 35 s, delay 10 s */}
<motion.div
  className="... bg-witch-turquoise/10 blur-[80px]"
  animate={{
    x:     ["0%", "40%", "0%"],
    y:     ["0%", "10%", "0%"],
    scale: [1,    1.1,   1],
  }}
  transition={{ duration: 35, repeat: Infinity, ease: "easeInOut", delay: 10 }}
/>
The staggered durations (25 s, 30 s, 35 s) and start delays (0 s, 5 s, 10 s) ensure the three blobs never peak simultaneously, so the color blending across the background shifts continuously rather than pulsing in unison.

Customization Tips

To slow down or speed up all CSS flicker animations at once, override animation-duration on .animate-flicker in a local stylesheet. The delay-class durations are independent and will need individual adjustment to maintain desynchronization at the new base speed.
To disable all Framer Motion page transitions — for example to reduce motion for accessibility — remove the <AnimatePresence> wrapper in the router. The motion.main initial and animate props will still run on first load; set them to { opacity: 1, filter: "brightness(1)" } to neutralize those as well.
The SmokeLayer loop is the most GPU-intensive animation in the project because of the large blurred surfaces. If performance is a concern on lower-end devices, reduce the blur radius on the blob elements or replace the scale keyframe with a smaller range such as [1, 1.05, 1].

Build docs developers (and LLMs) love