Animation Strategies: CSS vs Framer Motion
Learn when to use CSS animations versus JavaScript-based animation libraries in your React Shopify theme. Compare approaches, understand performance trade-offs, and choose the right tool for each use case.
Animation brings interfaces to life. A well-timed transition can guide users’ attention, provide feedback, and make your store feel polished and professional. But animation in e-commerce requires careful consideration—flashy effects that slow down the path to purchase hurt conversions.
In this lesson, we’ll explore the two main approaches to animation in React: CSS-based animations and JavaScript-based animation libraries like Framer Motion. You’ll learn when to use each approach and how to keep animations performant in a Shopify context.
The Animation Decision Tree
Before writing any animation code, ask yourself these questions:
┌─────────────────────────────────────────────────────────────┐│ ANIMATION DECISION TREE │├─────────────────────────────────────────────────────────────┤│ ││ Is it a simple state change? ││ (hover, focus, show/hide) ││ │ ││ ├── YES → Use CSS transitions ││ │ ││ └── NO ↓ ││ ││ Does it need complex choreography? ││ (staggered children, spring physics) ││ │ ││ ├── YES → Use Framer Motion ││ │ ││ └── NO ↓ ││ ││ Does it need to respond to gestures? ││ (drag, pinch, swipe) ││ │ ││ ├── YES → Use Framer Motion ││ │ ││ └── NO ↓ ││ ││ Is timing data-driven or dynamic? ││ │ ││ ├── YES → Use Framer Motion ││ │ ││ └── NO → Use CSS animations (@keyframes) ││ │└─────────────────────────────────────────────────────────────┘The principle is simple: use CSS for simple things, JavaScript for complex things. CSS animations are hardware-accelerated by default, don’t require additional JavaScript to download, and work even if JavaScript fails. Reserve Framer Motion for cases where CSS genuinely can’t do the job.
CSS Animations: The Default Choice
CSS should be your first choice for most animations in an e-commerce theme. Here’s why:
- Zero JavaScript overhead: No additional library code to download
- Hardware acceleration: Browsers optimize CSS animations automatically
- Resilient: Work even if JavaScript fails or is blocked
- Declarative: Easy to understand and maintain
CSS Transitions for State Changes
Transitions animate between two states. They’re perfect for hover effects, focus states, and show/hide toggles:
/* Button hover - simple opacity and transform */.button { transition: background-color 0.15s ease, transform 0.15s ease;}
.button:hover { background-color: var(--color-primary-dark); transform: translateY(-1px);}
.button:active { transform: translateY(0);}The key properties to animate are transform and opacity—these are compositor-only properties that browsers can animate without triggering expensive layout recalculations.
What NOT to Animate with CSS
Avoid animating these properties as they trigger layout or paint:
/* AVOID - triggers layout recalculation */.bad-animation { transition: width 0.3s, height 0.3s, margin 0.3s, padding 0.3s;}
/* BETTER - use transform instead */.good-animation { transition: transform 0.3s;}
.good-animation:hover { transform: scale(1.05); /* Instead of changing width/height */}CSS Variables for Dynamic Timing
CSS custom properties let you parameterize animations without JavaScript:
/* Define timing variables */:root { --duration-fast: 0.15s; --duration-normal: 0.25s; --duration-slow: 0.4s; --ease-out: cubic-bezier(0.33, 1, 0.68, 1); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);}
/* Use throughout your components */.dropdown { transition: opacity var(--duration-normal) var(--ease-out), transform var(--duration-normal) var(--ease-out);}
.dropdown[data-state="closed"] { opacity: 0; transform: translateY(-8px); pointer-events: none;}
.dropdown[data-state="open"] { opacity: 1; transform: translateY(0);}CSS Keyframe Animations
For multi-step animations that don’t depend on state, use @keyframes:
/* Loading spinner */@keyframes spin { to { transform: rotate(360deg); }}
.spinner { animation: spin 0.8s linear infinite;}
/* Skeleton loading pulse */@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; }}
.skeleton { animation: pulse 1.5s ease-in-out infinite;}
/* Slide in from bottom */@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); }}
.toast { animation: slideUp 0.3s var(--ease-out);}React Integration with CSS Animations
The challenge with CSS animations in React is timing unmounts. If you remove an element from the DOM immediately, users won’t see the exit animation. Here’s a pattern to handle this:
import { useState, useEffect, useRef } from 'react';
interface AnimatedPresenceProps { isVisible: boolean; children: React.ReactNode; duration?: number; // Match your CSS transition duration}
/** * Delays unmount to allow CSS exit animations to complete. * A lightweight alternative to Framer Motion's AnimatePresence. */export function AnimatedPresence({ isVisible, children, duration = 250,}: AnimatedPresenceProps) { const [shouldRender, setShouldRender] = useState(isVisible); const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { if (isVisible) { // Mount immediately when becoming visible setShouldRender(true); } else { // Delay unmount to allow exit animation timeoutRef.current = setTimeout(() => { setShouldRender(false); }, duration); }
return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, [isVisible, duration]);
if (!shouldRender) return null;
return <>{children}</>;}Use it in components:
import { AnimatedPresence } from '@/components/ui/AnimatedPresence';import styles from './CartDrawer.module.css';
interface CartDrawerProps { isOpen: boolean; onClose: () => void;}
export function CartDrawer({ isOpen, onClose }: CartDrawerProps) { return ( <AnimatedPresence isVisible={isOpen} duration={300}> {/* Overlay */} <div className={`${styles.overlay} ${isOpen ? styles.visible : ''}`} onClick={onClose} aria-hidden="true" />
{/* Drawer */} <aside className={`${styles.drawer} ${isOpen ? styles.open : ''}`} aria-label="Shopping cart" > {/* Cart contents */} </aside> </AnimatedPresence> );}.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); opacity: 0; transition: opacity 0.3s ease;}
.overlay.visible { opacity: 1;}
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(400px, 100vw); background: var(--color-background); transform: translateX(100%); transition: transform 0.3s var(--ease-out);}
.drawer.open { transform: translateX(0);}When to Use Framer Motion
Framer Motion shines when CSS can’t do the job. Here are the scenarios where it’s worth the bundle size cost:
1. Spring Physics
CSS can’t replicate natural spring physics. If you want that bouncy, organic feel:
import { motion } from 'framer-motion';
export function BouncyButton({ children, onClick }) { return ( <motion.button onClick={onClick} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} transition={{ type: 'spring', stiffness: 400, damping: 17, }} > {children} </motion.button> );}2. Staggered Children
Animating a list of items with delays between each:
import { motion } from 'framer-motion';
const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1, // 100ms delay between each child }, },};
const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 },};
export function ProductGrid({ products }) { return ( <motion.div variants={containerVariants} initial="hidden" animate="visible" className="product-grid" > {products.map((product) => ( <motion.div key={product.id} variants={itemVariants}> <ProductCard product={product} /> </motion.div> ))} </motion.div> );}3. Layout Animations
When elements need to animate smoothly as the DOM changes:
import { motion, AnimatePresence } from 'framer-motion';
export function CartItems({ items, onRemove }) { return ( <ul className="cart-items"> <AnimatePresence> {items.map((item) => ( <motion.li key={item.id} layout // Animate position changes initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} > <CartItem item={item} onRemove={() => onRemove(item.id)} /> </motion.li> ))} </AnimatePresence> </ul> );}4. Gesture-Based Interactions
Drag, swipe, and pinch gestures:
import { motion, useMotionValue, useTransform } from 'framer-motion';
export function SwipeableCard({ onSwipeLeft, onSwipeRight, children }) { const x = useMotionValue(0); const opacity = useTransform(x, [-200, 0, 200], [0.5, 1, 0.5]); const rotate = useTransform(x, [-200, 200], [-25, 25]);
return ( <motion.div style={{ x, opacity, rotate }} drag="x" dragConstraints={{ left: 0, right: 0 }} onDragEnd={(_, info) => { if (info.offset.x > 100) onSwipeRight(); if (info.offset.x < -100) onSwipeLeft(); }} > {children} </motion.div> );}Bundle Size Considerations
Framer Motion adds approximately 30KB (gzipped) to your bundle. For a Shopify theme where every kilobyte affects load time, this matters. Consider these strategies:
Tree-Shaking Specific Features
Only import what you need:
// Instead of importing everythingimport { motion, AnimatePresence } from 'framer-motion';
// Import specific features for smaller bundlesimport { motion } from 'framer-motion';// AnimatePresence imported separately only where neededLazy Loading Animation-Heavy Components
If animations are only needed on certain pages, lazy load:
import { lazy, Suspense } from 'react';
// Only load media gallery (with animations) when neededconst MediaGallery = lazy(() => import('@/components/product/MediaGallery'));
export function ProductPage({ product }) { return ( <div> <Suspense fallback={<MediaGallerySkeleton />}> <MediaGallery media={product.media} /> </Suspense> {/* Rest of product page */} </div> );}Consider motion/lazy Import
Framer Motion offers a lazy-loadable version:
import { LazyMotion, domAnimation, m } from 'framer-motion';
// Wrap your app or specific sectionsexport function AnimatedSection({ children }) { return ( <LazyMotion features={domAnimation}> {children} </LazyMotion> );}
// Use m instead of motion for components inside LazyMotionexport function AnimatedButton({ children }) { return ( <m.button whileHover={{ scale: 1.05 }}> {children} </m.button> );}Performance Profiling
Before deploying animations, test their performance:
// Development-only performance monitoringexport function AnimationProfiler({ children, id }) { if (process.env.NODE_ENV === 'development') { return ( <React.Profiler id={id} onRender={(id, phase, actualDuration) => { if (actualDuration > 16) { console.warn( `Animation "${id}" took ${actualDuration.toFixed(2)}ms ` + `(target: <16ms for 60fps)` ); } }} > {children} </React.Profiler> ); } return children;}Use Chrome DevTools’ Performance panel to identify:
- Layout thrashing: Multiple forced reflows
- Long frames: Frames taking >16ms
- Main thread blocking: JavaScript blocking animations
Practical Recommendations for Shopify Themes
Based on real-world Shopify theme development:
| Animation Type | Recommended Approach |
|---|---|
| Button hover/focus | CSS transitions |
| Dropdown menus | CSS transitions |
| Modal/drawer open/close | CSS with AnimatedPresence |
| Loading spinners | CSS @keyframes |
| Cart item add/remove | Framer Motion (layout) |
| Product gallery swipe | Framer Motion (gestures) |
| Mega menu appearance | CSS transitions |
| Notification toasts | CSS @keyframes |
| Quantity stepper | CSS transitions |
| Product card hover | CSS transitions |
| Filter panel slide | CSS transitions |
The 80/20 rule: 80% of your animations should use CSS. Reserve Framer Motion for the 20% of cases where it genuinely improves the experience.
Creating an Animation Utility Layer
Standardize your animation values across the theme:
export const duration = { instant: 0, fast: 150, normal: 250, slow: 400, slower: 600,} as const;
export const easing = { linear: 'linear', easeOut: 'cubic-bezier(0.33, 1, 0.68, 1)', easeIn: 'cubic-bezier(0.32, 0, 0.67, 0)', easeInOut: 'cubic-bezier(0.65, 0, 0.35, 1)', bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',} as const;
// Framer Motion spring presetsexport const spring = { gentle: { type: 'spring', stiffness: 120, damping: 14 }, snappy: { type: 'spring', stiffness: 400, damping: 30 }, bouncy: { type: 'spring', stiffness: 400, damping: 17 },} as const;
// CSS transition string builderexport function transition( properties: string[], durationMs: number = duration.normal, easingFn: string = easing.easeOut): string { return properties .map((prop) => `${prop} ${durationMs}ms ${easingFn}`) .join(', ');}Use consistently in components:
import { transition, duration, easing } from '@/lib/animation';
// In styled-components or inline stylesconst buttonStyles = { transition: transition(['background-color', 'transform'], duration.fast),};
// Or generate CSS custom propertiesexport function generateAnimationTokens() { return ` :root { --duration-fast: ${duration.fast}ms; --duration-normal: ${duration.normal}ms; --ease-out: ${easing.easeOut}; } `;}Key Takeaways
-
CSS first: Use CSS transitions and keyframes for simple animations. They’re faster, lighter, and more resilient.
-
Transform and opacity only: Stick to compositor-friendly properties to avoid layout thrashing.
-
Framer Motion for complexity: Reserve JavaScript animations for springs, staggered effects, layout animations, and gestures.
-
Bundle awareness: Framer Motion adds ~30KB. Lazy load it where possible.
-
Consistent timing: Create animation tokens/utilities to ensure consistent feel across your theme.
-
Profile in production: Test animations on real devices and slow connections. What feels smooth on your MacBook might jank on a budget Android phone.
-
Respect user preferences: Check
prefers-reduced-motionand disable or reduce animations for users who’ve requested it.
In the next lesson, we’ll apply these concepts to build page transitions and loading states that keep users engaged during data fetching.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...