Animation is UX, not decoration. When done right, it guides attention, provides feedback, and makes interfaces feel alive. When done wrong, it slows down your site and annoys users.
At Flip Beetle, our website is animation-heavy—yet it maintains 95+ Lighthouse performance scores. Here's how we do it.
The Purpose of Animation
Before animating anything, ask: Why?
Good Reasons to Animate
1. Provide Feedback Users need to know their actions worked.
- Button press states
- Form submission success
- Toggle switches
- Loading indicators
2. Guide Attention Direct users' focus where it matters.
- Scroll-triggered reveals
- Highlighting important information
- Modal appearances
- Notification alerts
3. Show Relationships Help users understand spatial relationships.
- Menu transitions
- Navigation between pages
- Expanding accordions
- Smooth scrolling
4. Add Polish Create memorable, delightful experiences.
- Micro-interactions
- Hover states
- Page transitions
- Easter eggs (used sparingly!)
Bad Reasons to Animate
- ❌ Because you can
- ❌ To show off technical skills
- ❌ Following trends blindly
- ❌ Filling empty space
- ❌ Distracting from poor content
The Golden Rule: 60fps or Nothing
Animations must run at 60 frames per second (16.67ms per frame). Anything less feels janky.
What You Can Animate (Performantly)
Only animate properties that don't trigger layout or paint:
- ✅
transform(translate, scale, rotate) - ✅
opacity
Why these work: GPU-accelerated, no reflow required.
What You Should Avoid
Properties that trigger expensive browser operations:
- ❌
width,height(triggers layout) - ❌
top,left,bottom,right(triggers layout) - ❌
margin,padding(triggers layout) - ❌
color,background-color(triggers paint, but less expensive)
Workaround: Use transform: scale() instead of animating width/height.
CSS Animations: The Foundation
CSS animations are fast and simple.
Basic Transition
.button {
background: var(--color-primary);
transform: scale(1);
transition: transform 0.2s ease-out;
}
.button:hover {
transform: scale(1.05);
}
Performance: Buttery smooth 60fps because we're only animating transform.
Keyframe Animation
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.element {
animation: fadeIn 0.4s ease-out;
}
Our Real Example: Flip Animation
@keyframes flip {
0%, 40% {
transform: rotate(180deg) translateY(-20px);
}
50%, 90% {
transform: rotate(0deg) translateY(0);
}
100% {
transform: rotate(180deg) translateY(-20px);
}
}
.beetle {
animation: flip 6s ease-in-out infinite;
}
Result: Our logo beetle flips continuously without impacting performance.
Framer Motion: Power with Simplicity
Framer Motion is our go-to library for complex animations.
Why Framer Motion?
Pros:
- Declarative API (readable, maintainable)
- Built for React
- Handles complex orchestration
- Excellent TypeScript support
- Accessibility features built-in
- Small bundle size (when tree-shaken)
Cons:
- Adds ~30KB (worth it for animation-heavy sites)
- React-only
- Learning curve
Basic Fade In
import { motion } from 'framer-motion';
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
Content
</motion.div>
Our Standard: Fade In Up
export const fadeInUp = {
hidden: {
opacity: 0,
y: 30,
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.43, 0.13, 0.23, 0.96], // Custom easing
},
},
};
// Usage
<motion.div
variants={fadeInUp}
initial="hidden"
animate="visible"
>
Content
</motion.div>
Scroll-Triggered Animations
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
function Section() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.3 });
return (
<motion.section
ref={ref}
initial={{ opacity: 0, y: 50 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 50 }}
transition={{ duration: 0.6 }}
>
Content appears when 30% visible
</motion.section>
);
}
Key parameters:
once: true- Only animate once (better performance)amount: 0.3- Trigger when 30% visible
Stagger Animations
Animate children sequentially for elegant reveals.
export const createStaggerContainer = (
staggerDelay: number = 0.1,
delayChildren: number = 0
) => ({
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
delayChildren,
staggerChildren: staggerDelay,
},
},
});
// Usage
<motion.div
variants={createStaggerContainer(0.1)}
initial="hidden"
animate="visible"
>
{items.map((item) => (
<motion.div key={item.id} variants={fadeInUp}>
{item.content}
</motion.div>
))}
</motion.div>
Result: Items appear one after another with 0.1s delays.
Parallax Scrolling
import { useScroll, useTransform, motion } from 'framer-motion';
function ParallaxSection() {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"]
});
const y = useTransform(scrollYProgress, [0, 1], [0, -50]);
return (
<section ref={ref}>
<motion.div style={{ y }}>
Parallax content
</motion.div>
</section>
);
}
Performance: useTransform is optimized—doesn't cause React re-renders on every scroll event.
Real-World Examples from Our Site
Example 1: Hero Animation
Our hero section has multi-part orchestrated animations.
const HeroSection = () => {
const controls = useAnimation();
useEffect(() => {
const sequence = async () => {
// 1. Progress bar fills
await controls.start("progressComplete");
// 2. Text reveal
await controls.start("textReveal");
// 3. Beetle appears
await controls.start("beetleAppear");
};
sequence();
}, []);
return (
<motion.section animate={controls}>
<ProgressBar variants={progressVariants} />
<Title variants={titleVariants} />
<BeetleVideo variants={beetleVariants} />
</motion.section>
);
};
Why it works: Sequence creates narrative, drawing users in.
Example 2: Blog Cards
Our blog cards use hover animations that feel tactile.
<motion.div
whileHover={{
scale: 1.05,
y: -5,
transition: { duration: 0.2 }
}}
className="blog-card"
>
<Image />
<Title />
<Excerpt />
</motion.div>
Performance tip: Only animate transform and opacity.
Example 3: Menu Transition
Our navigation menu animates in smoothly.
<motion.nav
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
>
<MenuContent />
</motion.nav>
Spring physics: Feels natural and responsive.
Example 4: Scroll Progress Bar
const ReadProgressBar = () => {
const { scrollYProgress } = useScroll();
const scaleX = useTransform(scrollYProgress, [0, 1], [0, 1]);
return (
<motion.div
className="progress-bar"
style={{ scaleX, transformOrigin: 'left' }}
/>
);
};
Result: Progress bar that updates smoothly as you scroll.
Performance Optimization Techniques
1. Use will-change (Sparingly)
Tell the browser what you'll animate:
.animated-element {
will-change: transform, opacity;
}
Warning: Overuse hurts performance. Only use on elements actively animating.
2. Reduce Motion for Accessibility
const shouldReduceMotion = useReducedMotion();
<motion.div
animate={shouldReduceMotion ? {} : { y: [0, -10, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
>
Bouncing element
</motion.div>
Our implementation:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
3. Lazy Load Heavy Animations
Don't load animation libraries on pages that don't need them.
// Code splitting
const HeavyAnimation = lazy(() => import('./HeavyAnimation'));
<Suspense fallback={<Placeholder />}>
<HeavyAnimation />
</Suspense>
4. Once is Often Enough
// Animate only once
useInView(ref, { once: true })
Why: Prevents re-triggering animations on scroll, saving CPU.
5. Debounce Resize Events
import { debounce } from 'lodash';
useEffect(() => {
const handleResize = debounce(() => {
// Handle resize
}, 150);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Animation Timing & Easing
Timing makes or breaks animations.
Duration Guidelines
- Micro-interactions: 100-200ms (button presses)
- Small transitions: 200-400ms (tooltips, dropdowns)
- Medium movements: 400-600ms (modal appears, sections fade in)
- Large transitions: 600-1000ms (page transitions)
- Never exceed: 1000ms (feels sluggish)
Easing Functions
Different easings for different purposes:
/* Ease out: decelerating (most common) */
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
/* Ease in: accelerating (for elements leaving) */
transition: transform 0.3s cubic-bezier(0.4, 0, 1, 1);
/* Ease in-out: smooth start and end */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Custom bounce */
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
Our standard easing:
:root {
--ease-smooth: cubic-bezier(0.43, 0.13, 0.23, 0.96);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
Common Animation Mistakes
1. Animating Too Much
Bad:
<motion.div
animate={{
x: [0, 10, -10, 10, 0],
y: [0, 5, -5, 5, 0],
rotate: [0, 5, -5, 5, 0],
scale: [1, 1.1, 0.9, 1.1, 1],
}}
transition={{ repeat: Infinity }}
>
Distracting!
</motion.div>
Good:
<motion.div
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
Subtle
</motion.div>
2. Inconsistent Timing
Use consistent durations across your site:
- 0.2s for quick interactions
- 0.4s for most transitions
- 0.6s for larger movements
3. Ignoring Reduced Motion
Always check prefers-reduced-motion.
4. Blocking Interactions
Don't make users wait for animations to complete:
// Bad: user waits 1s before interaction
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
/>
// Good: quick animation
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
5. Layout Shift
Animations shouldn't cause content jumps:
/* Reserve space */
.animated-element {
min-height: 200px;
}
Testing Animation Performance
Chrome DevTools
Performance tab:
- Start recording
- Interact with animations
- Check for frame drops (green bars should be consistent)
Rendering tab:
- Enable "Paint flashing" (see what repaints)
- Enable "Layer borders" (see GPU layers)
Lighthouse
Run Lighthouse audit:
- Performance score should be 90+
- Check for layout shifts
- Monitor JavaScript execution time
Real Device Testing
Desktop performance ≠ mobile performance. Test on:
- Mid-range Android phones
- Older iPhones
- Tablets
Our Animation Philosophy
At Flip Beetle, we follow these principles:
1. Purpose over pizzazz Every animation serves a UX goal.
2. Subtle beats showy Best animations go unnoticed—they just feel right.
3. Performance is non-negotiable 60fps or we don't ship it.
4. Accessibility first
Respect prefers-reduced-motion always.
5. Progressive enhancement Site works without animations, they're enhancements.
Our Complete Animation Stack
Libraries:
- Framer Motion (complex animations)
- CSS transitions (simple interactions)
- GSAP (when we need extra power, rarely)
Tools:
- Chrome DevTools (performance profiling)
- Lighthouse (auditing)
- React DevTools (component profiling)
Utilities:
useReducedMotionhookuseInViewfor scroll-triggered animations- Custom easing curves library
Real Results
Our animation-heavy website maintains:
- 98 Performance score (Lighthouse)
- 100 Accessibility score
- 2.8s First Contentful Paint
- 0.1s Largest Contentful Paint shift
How we do it:
- Only animate
transformandopacity - Use
once: truefor scroll animations - Respect reduced motion preferences
- Tree-shake unused animation code
- Lazy load heavy animations
Resources & Learning
Learn Framer Motion:
- Official Framer Motion docs
- Sam Selikoff's YouTube tutorials
- Frontend Masters courses
Animation inspiration:
- Codrops
- Awwwards
- Our own portfolio!
Performance:
- web.dev (animation performance guide)
- Google Chrome DevTools docs
Let's Animate Your Project
Animation is powerful when done right. At Flip Beetle, we've mastered the balance between delight and performance.
Want animations that enhance your site instead of slowing it down? Let's talk about your project and how motion can bring it to life.

