Motion
Principles
- Purposeful — Every animation communicates meaning: something appeared, changed, or needs attention.
- Fast — No animation exceeds 400ms. Hero entrance sequences are the only exception.
- Respectful — All animations are disabled when
prefers-reduced-motion: reduceis active.
Duration Scale
| Name | Value | Usage |
|---|---|---|
| Instant | 0ms | State changes with no perceptible transition (focus rings) |
| Fast | 150ms | Hover states, button press |
| Base | 200ms | Fade in/out, color transitions |
| Moderate | 300ms | Modal open/close, dropdown |
| Slow | 400ms | Page section entrance on scroll |
| Hero | 600ms–800ms | Above-the-fold staggered entrance |
Easing Presets
export const ease = { // Standard ease-out for entrances out: [0.0, 0.0, 0.2, 1], // Ease-in for exits in: [0.4, 0.0, 1, 1], // Smooth for transforms inOut: [0.4, 0.0, 0.2, 1], // Spring-like — for elements that 'settle' spring: { type: 'spring', stiffness: 300, damping: 30 },} as const;Framer Motion Variants
Fade Up (section entrance)
const fadeUp = { hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0, 0, 0.2, 1] } },};
<motion.div variants={fadeUp} initial="hidden" whileInView="visible" viewport={{ once: true }}> …</motion.div>Stagger Children (card grids)
const container = { hidden: {}, visible: { transition: { staggerChildren: 0.08 } },};
const item = { hidden: { opacity: 0, y: 16 }, visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },};
<motion.div variants={container} initial="hidden" whileInView="visible" viewport={{ once: true }}> {cards.map((c) => ( <motion.div key={c.id} variants={item}>{/* card */}</motion.div> ))}</motion.div>Button Press
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }}> …</motion.button>Ticker / Marquee (Press logos bar)
<motion.div animate={{ x: ['0%', '-50%'] }} transition={{ duration: 30, ease: 'linear', repeat: Infinity }}> {/* duplicated logo set */}</motion.div>Reduced Motion
Wrap all Framer Motion components with a reduced-motion check:
import { useReducedMotion } from 'framer-motion';
export function useMotionVariants(variants: Variants) { const reduced = useReducedMotion(); if (reduced) { return { hidden: {}, visible: {} }; } return variants;}Alternatively, set transition.duration: 0 when reduced is true.
CSS Transitions (Tailwind)
For non-Framer-Motion hover states, use Tailwind transition utilities:
| Class | Usage |
|---|---|
transition-colors | Color and background changes on hover |
transition-opacity | Fade in/out |
transition-transform | Scale/translate on hover |
duration-150 | Fast transitions (buttons) |
duration-200 | Standard transitions |
ease-out | Default easing |
<a class="transition-colors duration-150 ease-out hover:text-brand-red"> Link text</a>