Skip to main content

Documentation Index

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

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

Overview

All interactive and transitional animations in Dyed in the Wool are implemented with Framer Motion. The only CSS keyframe animation in the codebase is animate-blob, which handles the decorative pulsing circles in the hero background — everything else (page entry, scroll reveals, button hovers, modal expansions, cursor trails) is driven by Framer Motion variants. This document catalogs each animation pattern with its exact values so you can tune, extend, or disable them individually.

Animation Patterns

1. Fade + Slide Up — Page Section Entry

Used when a section or content block first appears in the viewport on page load.
initial:    { opacity: 0, y: 20 }
animate:    { opacity: 1, y: 0 }
transition: { duration: 0.8, delay: 0.2 }
The gentle upward drift (y: 20 → 0) combined with a fade communicates that content is “settling into place”. Increase y for a more dramatic entrance; reduce duration for snappier feel.

2. Spring Scale — Hero Headline

Applied to the main hero heading so it “breathes in” on load rather than simply fading.
initial:    { opacity: 0, scale: 0.9 }
animate:    { opacity: 1, scale: 1 }
transition: { duration: 1, type: 'spring', bounce: 0.4 }
The spring type with bounce: 0.4 gives the headline a subtle overshoot. Lower bounce toward 0 for a more refined, editorial feel; raise it toward 0.6 for a more playful pop.

3. Scroll-Triggered Reveal — Story Paragraphs

Each paragraph in the About / story section animates in as it enters the viewport, with a slight rotation that unwinds as it arrives.
initial:     { opacity: 0, y: 50, rotate: -5 }
whileInView: { opacity: 1, y: 0,  rotate: 0 }
viewport:    { once: true, margin: '-100px' }
transition:  { duration: 0.8, delay: index * 0.1 }
The rotate: -5 → 0 adds a hand-crafted, organic quality that reinforces the craft aesthetic. The margin: '-100px' means the animation triggers 100 px before the element fully enters the viewport, so it’s already in motion when the reader’s eye reaches it.
Always pass viewport: {{ once: true }} on scroll-triggered animations. Without it, the animation re-fires every time the element leaves and re-enters the viewport while the user scrolls back up — which feels glitchy and can distract from content.

4. Button Hover & Tap

Simple scale transforms give buttons a physical, pressable quality.
whileHover: { scale: 1.05 }
whileTap:   { scale: 0.95 }
The tap scale slightly smaller than 1 simulates the button physically depressing under a finger or click. Keep the hover scale modest (1.051.08) to avoid making buttons feel oversized.

5. Button Fill — Radial Expand

A motion.div sits inside each CTA button and expands from a circle to fill the entire button on hover, creating an ink-bleed effect.
// Inner fill element
initial:    { scale: 0,   opacity: 0, borderRadius: '100%' }
whileHover: { scale: 1.5, opacity: 1, borderRadius: '0%' }
transition: { duration: 0.4, ease: 'easeOut' }
The fill element is positioned absolute and uses the dye-magenta background color, so hovering reveals the accent color bleeding outward from the center. Increase scale beyond 1.5 if the button is wide and the fill doesn’t reach the edges.

6. Page Transition — Blur Fade

Used with AnimatePresence mode="wait" around the router outlet. When navigating between pages, the outgoing page blurs and fades, and the incoming page unblurs and fades in.
initial:    { opacity: 0, filter: 'blur(10px)' }
animate:    { opacity: 1, filter: 'blur(0px)' }
exit:       { opacity: 0, filter: 'blur(10px)' }
transition: { duration: 0.5 }
The blur gives the transition a cinematic, “focus pull” quality. The mode="wait" on AnimatePresence ensures the exit animation completes before the enter animation starts — critical for the blur effect to feel intentional rather than chaotic.

7. Project Card Modal — layoutId Shared Element

The project grid cards expand into a full modal using Framer Motion’s shared layout animation.
// On the grid card:
<motion.div layoutId={`card-${id}`}>...</motion.div>

// On the expanded modal:
<motion.div layoutId={`card-${id}`}>...</motion.div>
Framer Motion automatically calculates the position, size, and border-radius difference between the card and the modal and animates between them. No explicit initial / animate values are needed — the layoutId match is sufficient. Ensure the modal is rendered in the same AnimatePresence tree so Framer can coordinate the transition.

8. Ink Drop Cursor Trail

Each drop in the DyeDropCursor trail spawns a motion.div that expands outward and fades.
initial:    { opacity: 0.8, scale: 0 }
animate:    { opacity: 0,   scale: 2 }
transition: { duration: 1,  ease: 'easeOut' }
Each circle starts fully opaque and tiny, then expands to twice its natural size while fading to transparent — simulating a drop of dye blooming in water. The color cycles through the P array (see Color Palette → Updating the DyeDropCursor Trail).

9. Nav Indicator — Shared Layout Underline

The active-route underline in the navigation uses a shared layout element so it slides smoothly between links when the route changes.
<motion.div
  layoutId="nav-indicator"
  transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
Only one instance of the element with layoutId="nav-indicator" should exist in the DOM at any time — the currently active nav item renders it. Framer Motion tracks its position and animates it to the new location whenever the active link changes. The stiffness: 300 / damping: 30 spring feels snappy without overshooting.

10. Cursor — Spring Follow

The DyeDropCursor dot that tracks the pointer uses a spring transition so it trails the mouse with natural physics rather than teleporting.
// motion.div animated on every mouse move
animate:    { x, y, width, height, backgroundColor }
transition: { type: 'spring', stiffness: 500, damping: 28, mass: 0.5 }
The high stiffness: 500 keeps the cursor close to the pointer at all times, while damping: 28 and mass: 0.5 prevent excessive oscillation. Lowering stiffness creates a lazier, more exaggerated trail; raising damping makes it feel heavier and less springy.

Accessibility — Respecting Reduced Motion

Users who have enabled the “reduce motion” preference in their OS should not see large-scale movement or rapid transitions. Use Framer Motion’s useReducedMotion hook to conditionally disable or minimise animations:
import { useReducedMotion } from 'framer-motion';

function MyComponent() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      animate={{ opacity: 1, y: shouldReduceMotion ? 0 : -20 }}
    />
  );
}
When shouldReduceMotion is true, skip the y offset entirely so the element simply fades in without moving. Apply the same pattern to scale, rotate, and filter values — opacity-only transitions are generally safe to keep.

Stagger Delays

Several lists and grids in the portfolio create a cascade effect by multiplying the animation delay by the item’s index:
transition: { duration: 0.8, delay: index * 0.1 }
The first item animates immediately (delay: 0), the second after 100 ms, the third after 200 ms, and so on. This gives the impression that items are appearing in sequence rather than all at once.
For long lists, cap the maximum delay to keep the total stagger under ~600 ms — for example delay: Math.min(index * 0.1, 0.6). An unbounded stagger on a 10-item list would make the last item wait a full second, which feels slow and can frustrate users who scroll quickly.

Build docs developers (and LLMs) love