Skip to main content

Documentation Index

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

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

Dev Nexus layered its motion system into four distinct tiers, each served by a different Framer Motion primitive. Understanding which tier handles what makes it straightforward to adjust timing, add new animations, or strip motion for accessibility.
LayerMechanismScope
Page-levelPageTransition wrapper + AnimatePresenceEntire route entering / exiting
Element-levelmotion.div with initial / animateIndividual cards, headings, sections
Continuousanimate={{ rotate: 360 }} loopSigil rings, ambient background elements
Interactivelayout prop + AnimatePresenceVialCard expand/collapse

Page Transitions

Every route is wrapped in a shared PageTransition component. It lives at the router level so the entrance animation fires once per navigation, not once per component mount:
// components/PageTransition.jsx
import { motion } from 'framer-motion';

export default function PageTransition({ children }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -20 }}
      transition={{ duration: 0.4, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}
AnimatePresence in the router must use mode="wait" so the exiting page fully fades out before the entering page begins its animation. Without it, both pages are visible simultaneously during the crossfade:
// App.jsx (router outlet)
import { AnimatePresence } from 'framer-motion';
import { useLocation } from 'react-router-dom';

export default function App() {
  const location = useLocation();
  return (
    <AnimatePresence mode="wait">
      <Routes location={location} key={location.pathname}>
        <Route path="/" element={<PageTransition><Home /></PageTransition>} />
        {/* ...other routes */}
      </Routes>
    </AnimatePresence>
  );
}

Scroll-Triggered Reveals

Sections and cards use whileInView to animate in as the user scrolls down. The viewport config controls when the trigger fires:
<motion.div
  initial={{ opacity: 0, y: 20 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: '-100px' }}
  transition={{ duration: 0.5 }}
>
  Content
</motion.div>
  • once: true — the animation fires exactly once. Scrolling back up and then down again does not re-trigger it. Remove this flag only if you want the element to animate every time it enters the viewport.
  • margin: '-100px' — the element starts animating when it is 100px inside the viewport boundary, giving the reveal a comfortable lead so it never appears to pop in at the bottom edge of the screen.

Staggered Entrance Delays

Page sections that contain multiple sibling elements — skill bars, project cards, nav links — stagger their transition.delay values to create a cascading entrance rather than everything appearing at once:
{items.map((item, index) => (
  <motion.div
    key={item.id}
    initial={{ opacity: 0, y: 20 }}
    animate={{ opacity: 1, y: 0 }}
    transition={{
      duration: 0.5,
      delay: 0.5 + index * 0.1, // 0.5, 0.6, 0.7, 0.8...
    }}
  >
    {item.content}
  </motion.div>
))}
For fixed sets of elements (hero section layers, for example), delay values are hard-coded at 0.5, 0.8, 1.0, 1.1, 1.2 to give the designer precise control over the cascade rhythm rather than an even mechanical step.

Continuous Rotation

The hero sigil’s outer decorative ring spins indefinitely at a slow, meditative pace. The ease: 'linear' value is critical — any other easing would produce a visible acceleration/deceleration on every revolution:
<motion.div
  animate={{ rotate: 360 }}
  transition={{ duration: 40, repeat: Infinity, ease: 'linear' }}
/>
Adjust duration to change the rotation speed. A value of 20 is noticeably brisk; 60 is almost imperceptibly slow. Inner rings or counter-rotating elements use negative rotate targets:
{/* Counter-rotating inner ring */}
<motion.div
  animate={{ rotate: -360 }}
  transition={{ duration: 25, repeat: Infinity, ease: 'linear' }}
/>

VialCard Expand / Collapse

The Projects page VialCard uses two Framer Motion features together: the layout prop for smooth height changes on the card container, and AnimatePresence to animate the expanded content in and out of the DOM:
import { motion, AnimatePresence } from 'framer-motion';

function VialCard({ project, isExpanded, onToggle }) {
  return (
    // layout prop tells Framer Motion to animate any size/position
    // changes caused by the expanding child content
    <motion.div layout className="glass-panel rounded-xl overflow-hidden">

      <button onClick={onToggle}>
        {project.title}
      </button>

      <AnimatePresence>
        {isExpanded && (
          <motion.div
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: 'auto' }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.3, ease: 'easeInOut' }}
          >
            <p>{project.description}</p>
          </motion.div>
        )}
      </AnimatePresence>

    </motion.div>
  );
}
The height: 0height: 'auto' tween is handled natively by Framer Motion. In vanilla CSS, animating to height: auto is not possible — this is one of the primary reasons the component reaches for Framer Motion rather than a CSS transition.

CSS Float Animation

Bubble particles inside VialCards use a pure-CSS keyframe defined in main.css rather than Framer Motion, because they run continuously and independently of any React state:
@keyframes float {
  0%   { transform: translateY(0); }
  50%  { transform: translateY(-10px); }
  100% { transform: translateY(0); }
}

.animate-float {
  animation: float 6s ease-in-out infinite;
}
Apply staggered animation-delay values in JSX to prevent all bubbles from moving in perfect unison:
{bubbles.map((bubble, i) => (
  <div
    key={i}
    className="animate-float rounded-full bg-electric-violet/20"
    style={{ animationDelay: `${i * 0.8}s` }}
  />
))}

Tips and Accessibility

Respect prefers-reduced-motion. Framer Motion exposes a useReducedMotion hook that returns true when the user has enabled the OS-level reduced-motion preference. Wrap your variant definitions in a check so animations degrade gracefully:
import { motion, useReducedMotion } from 'framer-motion';

function AnimatedCard({ children }) {
  const reduceMotion = useReducedMotion();

  const variants = {
    hidden: { opacity: 0, y: reduceMotion ? 0 : 20 },
    visible: { opacity: 1, y: 0 },
  };

  return (
    <motion.div
      initial="hidden"
      animate="visible"
      variants={variants}
      transition={{ duration: reduceMotion ? 0 : 0.5 }}
    >
      {children}
    </motion.div>
  );
}
When reduceMotion is true, elements still transition to their visible state — they just do so instantly without movement or a fade delay. Content remains fully accessible without the spatial motion that can cause discomfort for some users.
viewport={{ once: true }} prevents scroll-triggered animations from re-firing when the user scrolls back up through already-seen content. This is the default preference throughout Dev Nexus because re-triggering feels unpolished in a portfolio context. mode="wait" on AnimatePresence at the router level ensures the exit animation of the departing page completes before the entering page begins. Without it, two pages render simultaneously during the transition and the layout shifts unpredictably.

Build docs developers (and LLMs) love