Skip to main content

Documentation Index

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

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

Framer Motion is the animation engine that brings Groovy Portfolio to life. Rather than relying on CSS keyframes scattered across stylesheets, every motion effect — from the blurry page fade-in to the spinning vinyl record disc — is expressed declaratively in JSX using motion.* primitives. This makes animations easy to read, tweak, and reason about. motion components are imported through a single re-export file (assets/proxy.js) while AnimatePresence is re-exported from assets/index.js, giving the codebase two canonical, stable import paths. The sections below document every distinct animation pattern used in the project, with runnable code examples for each.
motion.* components (e.g. motion.div, motion.h1) are imported from assets/proxy.js, while AnimatePresence is imported from assets/index.js. Always use these internal re-exports rather than importing directly from framer-motion.
// ✅ Correct
import { m as motion } from "../assets/proxy.js";
import { A as AnimatePresence } from "../assets/index.js";

// ❌ Avoid importing directly
import { motion, AnimatePresence } from "framer-motion";

1. Page Transitions

Every route change triggers a blur-and-scale entrance/exit managed by AnimatePresence inside Layout.js. The exiting page scales up and blurs out while the entering page scales up from slightly below full size and sharpens into focus. The animation is keyed by location.pathname so React knows when to swap pages.
// Layout.js
import { m as motion } from "../assets/proxy.js";
import { A as AnimatePresence } from "../assets/index.js";
import { useLocation } from "react-router-dom";

export default function Layout({ children }) {
  const location = useLocation();

  return (
    <AnimatePresence mode="wait">
      <motion.main
        key={location.pathname}
        initial={{ opacity: 0, scale: 0.95, filter: "blur(10px)" }}
        animate={{ opacity: 1, scale: 1,    filter: "blur(0px)" }}
        exit={{    opacity: 0, scale: 1.05, filter: "blur(10px)" }}
        transition={{ duration: 0.5, ease: "easeInOut" }}
      >
        {children}
      </motion.main>
    </AnimatePresence>
  );
}
Note that the initial and exit states differ: the page enters from scale: 0.95 (slightly smaller) and exits toward scale: 1.05 (slightly larger), creating a natural push-through feel between routes.

2. Hero Entrance Animations

The hero section on the Home page animates in as a single unit — the entire centered container slides up from 50 px below and fades in, with the tagline pill following on a separate delayed spring.
// Hero container — slides up and fades in
<motion.div
  initial={{ y: 50, opacity: 0 }}
  animate={{ y: 0,  opacity: 1 }}
  transition={{ duration: 1, delay: 0.2 }}
  className="text-center z-10"
>
  {/* Hero title — hover interaction applied directly */}
  <motion.h1
    className="font-groovy text-7xl text-teal-dark drop-shadow-[0_4px_4px_rgba(217,70,239,0.5)]"
    whileHover={{ scale: 1.05, rotate: -2 }}
  >
    Alex Groovy
  </motion.h1>

  {/* Tagline pill — scales in after a longer delay */}
  <motion.p
    className="text-groovy-violet bg-cream/60 border-2 border-groovy-violet inline-block px-6 py-2 rounded-full"
    initial={{ scale: 0.8, opacity: 0 }}
    animate={{ scale: 1,   opacity: 1 }}
    transition={{ duration: 0.5, delay: 0.8 }}
  >
    Good vibes only (and good code)
  </motion.p>
</motion.div>
The hero title responds to mouse hover with a subtle scale-up and counter-clockwise tilt. The combination of scale and rotation gives the feeling of a poster being picked up off a table. No explicit transition is needed on the whileHover — Framer Motion’s default spring snaps it back elastically when the cursor leaves.

3. Spring Physics

Spring physics are used wherever an animation needs to feel physically weighty rather than timed. The VinylRecord component slides its disc into view using type: "spring" so it overshoots slightly and settles, mimicking a real vinyl record being slid out of its sleeve. The same spring configuration is used for both the disc slide-out and the album cover slide-in.
// VinylRecord — disc slides out with spring physics when opened
<motion.div
  animate={{ x: isOpen ? 160 : isHovered ? 40 : 0 }}
  transition={{
    type: "spring",
    stiffness: 100,
    damping: 15,
  }}
  className="vinyl-disc"
/>

// VinylRecord — disc spins continuously while open (linear, no easing)
<motion.div
  animate={{ rotate: isOpen ? 360 : isHovered ? 45 : 0 }}
  transition={
    isOpen
      ? { duration: 2, repeat: Infinity, ease: "linear" }
      : { duration: 0.5, ease: "easeOut" }
  }
  className="vinyl-disc"
/>
The ease: "linear" on the spin ensures constant rotational speed — no acceleration or deceleration artifacts.

4. Viewport-Triggered Animations

PeaceTimeline cards animate in as the user scrolls them into view using whileInView. The once: true option means the animation fires only the first time each card enters the viewport. A negative margin of -100px triggers the animation slightly before the element fully enters the viewport so it is already mid-animation by the time the user’s eye reaches it. Alternate cards start with an opposite rotation so they tilt in from different directions.
// PeaceTimeline card — animates when scrolled into view
// Even-indexed cards tilt from -10deg; odd-indexed from +10deg
<motion.div
  initial={{ opacity: 0, y: 50, rotate: isEven ? -10 : 10 }}
  whileInView={{ opacity: 1, y: 0, rotate: 0 }}
  viewport={{ once: true, margin: "-100px" }}
  transition={{ duration: 0.6, type: "spring" }}
  className="timeline-card"
>
  <TimelineEntry event={event} />
</motion.div>

5. Infinite Loop Animations

Two components use infinite animation loops. GroovyBubble floats up and down continuously to create a sense of weightlessness. The float is driven by a y keyframe array relative to the bubble’s yOffset starting position, so each bubble floats independently from wherever it was placed.
// GroovyBubble — entrance (opacity + x spring) + continuous y-float
// The y keyframes are relative to the bubble's yOffset prop
<motion.div
  initial={{ opacity: 0, x: direction === "left" ? -50 : 50 }}
  animate={{
    opacity: 1,
    x: 0,
    y: [yOffset, yOffset - 15, yOffset],
  }}
  transition={{
    opacity: { duration: 0.8, delay: delay },
    x: { duration: 0.8, delay: delay, type: "spring" },
    y: { duration: 4, repeat: Infinity, ease: "easeInOut", delay: delay % 2 },
  }}
  className="groovy-bubble"
/>
The y: [yOffset, yOffset - 15, yOffset] keyframe array drives the bubble through a smooth sine-like float cycle of 15 px amplitude.

6. Layout Animations and Shared-Element Transitions

WallPoster uses Framer Motion’s layoutId feature to create a seamless shared-element transition between a thumbnail card and its expanded modal. When a user clicks a poster thumbnail, the element visually morphs (size, position, border-radius) into the full-screen modal without a hard cut. The layoutId is derived from the poster’s title prop.
// WallPoster thumbnail — hover scales it up
<motion.div
  layoutId={`poster-${title}`}
  whileHover={{ scale: 1.05 }}
  onClick={() => setOpen(true)}
  className="poster-thumbnail cursor-pointer"
>
  <h3 className="font-groovy text-3xl">{title}</h3>
</motion.div>

// WallPoster modal (rendered inside AnimatePresence)
<AnimatePresence>
  {open && (
    <motion.div
      layoutId={`poster-${title}`}
      className="poster-modal fixed inset-0 z-50 bg-cream"
    >
      <h2 className="font-groovy text-5xl">{title}</h2>
      <button onClick={() => setOpen(false)}>×</button>
    </motion.div>
  )}
</AnimatePresence>
The same layoutId string on both elements tells Framer Motion they are the same logical element at different sizes, enabling the smooth morph transition.

7. AnimatePresence for Conditional Content

AnimatePresence is used in two places beyond page transitions: the WallPoster modal (shown above) and the DaisyForm success state, where 30 confetti circles burst outward in a radial pattern after submission. The petals (form fields) exit by scaling down to zero and fading out, and the confetti circles animate outward from the center with a duration of 1.5 s.
// DaisyForm — petals exit when submitting; confetti bursts on success
<AnimatePresence>
  {!submitted &&
    petals.map((petal, i) => (
      <motion.div
        key={petal.id}
        initial={{ rotate: petal.rotate, scale: 0 }}
        animate={{ rotate: petal.rotate, scale: 1 }}
        exit={{ scale: 0, opacity: 0 }}
        transition={{ duration: 0.5, delay: i * 0.1 }}
        className="absolute petal"
        style={{ backgroundColor: petal.color }}
      />
    ))}
</AnimatePresence>

<AnimatePresence>
  {submitted &&
    Array.from({ length: 30 }).map((_, i) => {
      const colors = ["#d946ef","#fde047","#a3e635","#38bdf8","#8b5cf6","#2dd4bf"];
      const angle = (i / 30) * Math.PI * 2;
      const radius = 150 + Math.random() * 100;
      return (
        <motion.div
          key={i}
          className="absolute top-1/2 left-1/2 w-4 h-4 rounded-full"
          style={{ backgroundColor: colors[i % colors.length] }}
          initial={{ x: 0, y: 0, scale: 0 }}
          animate={{
            x: Math.cos(angle) * radius,
            y: Math.sin(angle) * radius,
            scale: [0, 1, 0],
            opacity: [1, 1, 0],
          }}
          transition={{ duration: 1.5, ease: "easeOut" }}
        />
      );
    })}
</AnimatePresence>
To respect users who have enabled the prefers-reduced-motion OS setting, wrap your animation values with Framer Motion’s useReducedMotion hook. When it returns true, pass static values instead of animated ones:
import { useReducedMotion } from "framer-motion";

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

  return (
    <motion.div
      initial={{ y: shouldReduce ? 0 : 50, opacity: 0 }}
      animate={{ y: 0, opacity: 1 }}
      transition={{ duration: shouldReduce ? 0 : 0.6 }}
    >
      Content
    </motion.div>
  );
}
This ensures the portfolio is fully accessible to users sensitive to motion without removing animations for everyone else.

Build docs developers (and LLMs) love