Animation and Micro-interactions Intermediate 10 min read

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:

  1. Zero JavaScript overhead: No additional library code to download
  2. Hardware acceleration: Browsers optimize CSS animations automatically
  3. Resilient: Work even if JavaScript fails or is blocked
  4. 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:

src/styles/animations.css
/* 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:

src/components/ui/AnimatedPresence.tsx
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:

src/components/layout/CartDrawer.tsx
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>
);
}
CartDrawer.module.css
.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 everything
import { motion, AnimatePresence } from 'framer-motion';
// Import specific features for smaller bundles
import { motion } from 'framer-motion';
// AnimatePresence imported separately only where needed

Lazy Loading Animation-Heavy Components

If animations are only needed on certain pages, lazy load:

src/entries/product-page.tsx
import { lazy, Suspense } from 'react';
// Only load media gallery (with animations) when needed
const 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 sections
export function AnimatedSection({ children }) {
return (
<LazyMotion features={domAnimation}>
{children}
</LazyMotion>
);
}
// Use m instead of motion for components inside LazyMotion
export function AnimatedButton({ children }) {
return (
<m.button whileHover={{ scale: 1.05 }}>
{children}
</m.button>
);
}

Performance Profiling

Before deploying animations, test their performance:

// Development-only performance monitoring
export 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 TypeRecommended Approach
Button hover/focusCSS transitions
Dropdown menusCSS transitions
Modal/drawer open/closeCSS with AnimatedPresence
Loading spinnersCSS @keyframes
Cart item add/removeFramer Motion (layout)
Product gallery swipeFramer Motion (gestures)
Mega menu appearanceCSS transitions
Notification toastsCSS @keyframes
Quantity stepperCSS transitions
Product card hoverCSS transitions
Filter panel slideCSS 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:

src/lib/animation.ts
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 presets
export 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 builder
export 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 styles
const buttonStyles = {
transition: transition(['background-color', 'transform'], duration.fast),
};
// Or generate CSS custom properties
export function generateAnimationTokens() {
return `
:root {
--duration-fast: ${duration.fast}ms;
--duration-normal: ${duration.normal}ms;
--ease-out: ${easing.easeOut};
}
`;
}

Key Takeaways

  1. CSS first: Use CSS transitions and keyframes for simple animations. They’re faster, lighter, and more resilient.

  2. Transform and opacity only: Stick to compositor-friendly properties to avoid layout thrashing.

  3. Framer Motion for complexity: Reserve JavaScript animations for springs, staggered effects, layout animations, and gestures.

  4. Bundle awareness: Framer Motion adds ~30KB. Lazy load it where possible.

  5. Consistent timing: Create animation tokens/utilities to ensure consistent feel across your theme.

  6. Profile in production: Test animations on real devices and slow connections. What feels smooth on your MacBook might jank on a budget Android phone.

  7. Respect user preferences: Check prefers-reduced-motion and 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...