Animation and Micro-interactions Intermediate 12 min read

Page Transitions and Loading States

Create smooth page transitions and engaging loading states in your React Shopify theme. Learn skeleton screens, optimistic updates, and transition patterns that keep users engaged during data fetching.

Nothing kills the shopping experience like staring at a blank screen. When customers click to view a product or add items to their cart, they need immediate feedback that something is happening. In this lesson, we’ll build loading states and transitions that keep users engaged and reduce perceived wait times.

The Psychology of Loading States

Research shows that perceived wait time matters more than actual wait time. A 2-second load that shows progress feels faster than a 1-second load with a blank screen. Our goals:

  1. Immediate feedback: Show something changed within 100ms of user action
  2. Progress indication: Let users know the system is working
  3. Content preview: Show the shape of upcoming content (skeleton screens)
  4. Graceful completion: Smooth transition when content arrives
┌─────────────────────────────────────────────────────────────────────┐
│ LOADING STATE TIMELINE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 0ms 100ms 200ms 1000ms 2000ms+ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ User Optimistic Skeleton Content Consider │
│ Action Update Appears Arrives Timeout │
│ (cart +1) (shapes) (fade in) Message │
│ │
│ ──────────────────────────────────────────────────────────────── │
│ "Instant" "Loading" "Complete" │
│ │
└─────────────────────────────────────────────────────────────────────┘

Skeleton Screens

Skeleton screens show the shape of content before it loads. They’re more effective than spinners because they set expectations about what’s coming.

Building a Skeleton Component

src/components/ui/Skeleton/Skeleton.tsx
import styles from './Skeleton.module.css';
interface SkeletonProps {
width?: string | number;
height?: string | number;
borderRadius?: string;
className?: string;
}
/**
* Skeleton placeholder that pulses while content loads.
* Use to match the approximate shape of the content being loaded.
*/
export function Skeleton({
width = '100%',
height = '1em',
borderRadius = '4px',
className,
}: SkeletonProps) {
return (
<div
className={`${styles.skeleton} ${className || ''}`}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
borderRadius,
}}
aria-hidden="true"
/>
);
}
// Preset variants for common use cases
export function SkeletonText({ lines = 3, lastLineWidth = '60%' }) {
return (
<div className={styles.textBlock}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
height="1em"
width={i === lines - 1 ? lastLineWidth : '100%'}
className={styles.textLine}
/>
))}
</div>
);
}
export function SkeletonImage({ aspectRatio = '1' }) {
return (
<div style={{ aspectRatio }} className={styles.imagePlaceholder}>
<Skeleton width="100%" height="100%" borderRadius="8px" />
</div>
);
}
Skeleton.module.css
.skeleton {
background: linear-gradient(
90deg,
var(--color-skeleton, #e0e0e0) 25%,
var(--color-skeleton-highlight, #f0f0f0) 50%,
var(--color-skeleton, #e0e0e0) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.textBlock {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.textLine {
/* Lines have slight variation in timing for natural feel */
}
.textLine:nth-child(odd) {
animation-delay: 0.1s;
}
.imagePlaceholder {
overflow: hidden;
border-radius: 8px;
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: var(--color-skeleton, #e0e0e0);
}
}

Product Card Skeleton

Match the skeleton to your actual product card layout:

src/components/product/ProductCard/ProductCardSkeleton.tsx
import { Skeleton, SkeletonText } from '@/components/ui/Skeleton';
import styles from './ProductCard.module.css';
/**
* Skeleton version of ProductCard.
* Matches the exact dimensions of the real card for seamless transition.
*/
export function ProductCardSkeleton() {
return (
<div className={styles.card} aria-hidden="true">
{/* Image placeholder - matches product image aspect ratio */}
<div className={styles.imageWrapper}>
<Skeleton width="100%" height="100%" />
</div>
<div className={styles.details}>
{/* Vendor name */}
<Skeleton width="60px" height="12px" />
{/* Product title - typically 1-2 lines */}
<Skeleton width="100%" height="18px" className={styles.titleSkeleton} />
<Skeleton width="70%" height="18px" />
{/* Price */}
<Skeleton width="80px" height="20px" className={styles.priceSkeleton} />
</div>
</div>
);
}

Product Grid with Loading State

src/components/collection/ProductGrid.tsx
import { ProductCard } from '@/components/product/ProductCard';
import { ProductCardSkeleton } from '@/components/product/ProductCard/ProductCardSkeleton';
import styles from './ProductGrid.module.css';
interface ProductGridProps {
products: Product[] | null; // null = loading
isLoading?: boolean;
itemCount?: number; // How many skeletons to show
}
export function ProductGrid({
products,
isLoading,
itemCount = 12,
}: ProductGridProps) {
// Show skeletons when loading or no products yet
if (isLoading || products === null) {
return (
<div className={styles.grid} aria-busy="true" aria-label="Loading products">
{Array.from({ length: itemCount }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
);
}
// Empty state
if (products.length === 0) {
return (
<div className={styles.emptyState}>
<p>No products found</p>
</div>
);
}
// Loaded state - animate products in
return (
<div className={styles.grid}>
{products.map((product, index) => (
<div
key={product.id}
className={styles.gridItem}
style={{ '--animation-delay': `${index * 50}ms` } as React.CSSProperties}
>
<ProductCard product={product} />
</div>
))}
</div>
);
}
ProductGrid.module.css
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.gridItem {
opacity: 0;
transform: translateY(10px);
animation: fadeInUp 0.4s ease forwards;
animation-delay: var(--animation-delay, 0ms);
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Limit stagger effect to first 12 items */
.gridItem:nth-child(n + 13) {
animation-delay: 0ms;
}
.emptyState {
grid-column: 1 / -1;
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.gridItem {
opacity: 1;
transform: none;
animation: none;
}
}

Loading Indicators for Actions

For discrete actions like adding to cart, use inline loading indicators rather than full-page skeletons:

src/components/product/AddToCartButton.tsx
import { useState } from 'react';
import { useCart } from '@/hooks/useCart';
import { Spinner } from '@/components/ui/Spinner';
import { CheckIcon } from '@/components/icons';
import styles from './AddToCartButton.module.css';
type ButtonState = 'idle' | 'loading' | 'success' | 'error';
interface AddToCartButtonProps {
variantId: string;
quantity: number;
disabled?: boolean;
}
export function AddToCartButton({
variantId,
quantity,
disabled,
}: AddToCartButtonProps) {
const [state, setState] = useState<ButtonState>('idle');
const { addItem } = useCart();
const handleClick = async () => {
if (state === 'loading') return;
setState('loading');
try {
await addItem(variantId, quantity);
setState('success');
// Reset to idle after showing success
setTimeout(() => setState('idle'), 2000);
} catch (error) {
setState('error');
setTimeout(() => setState('idle'), 3000);
}
};
return (
<button
className={`${styles.button} ${styles[state]}`}
onClick={handleClick}
disabled={disabled || state === 'loading'}
aria-busy={state === 'loading'}
>
<span className={styles.content}>
{state === 'idle' && 'Add to Cart'}
{state === 'loading' && (
<>
<Spinner size={18} />
<span>Adding...</span>
</>
)}
{state === 'success' && (
<>
<CheckIcon />
<span>Added!</span>
</>
)}
{state === 'error' && 'Try Again'}
</span>
</button>
);
}
AddToCartButton.module.css
.button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 2rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.2s ease,
transform 0.1s ease;
overflow: hidden;
}
.button:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.button:active:not(:disabled) {
transform: scale(0.98);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.content {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Success state */
.button.success {
background: var(--color-success);
}
/* Error state */
.button.error {
background: var(--color-error);
}
/* Smooth transitions between states */
.content > * {
transition: opacity 0.15s ease, transform 0.15s ease;
}

Page Transition Patterns

In a traditional Shopify theme, page transitions are handled by the browser. With React components, we can add transitions within our app:

Content Area Transitions

When filtering or sorting changes content without a full page load:

src/components/collection/CollectionContent.tsx
import { useRef, useEffect, useState } from 'react';
import styles from './CollectionContent.module.css';
interface CollectionContentProps {
products: Product[];
filterKey: string; // Changes when filters change
}
export function CollectionContent({ products, filterKey }: CollectionContentProps) {
const [isTransitioning, setIsTransitioning] = useState(false);
const [displayedProducts, setDisplayedProducts] = useState(products);
const prevKeyRef = useRef(filterKey);
useEffect(() => {
// When filter changes, trigger transition
if (filterKey !== prevKeyRef.current) {
setIsTransitioning(true);
// After fade out, update content
const timer = setTimeout(() => {
setDisplayedProducts(products);
setIsTransitioning(false);
}, 200); // Match CSS transition duration
prevKeyRef.current = filterKey;
return () => clearTimeout(timer);
} else {
// No filter change, update immediately
setDisplayedProducts(products);
}
}, [filterKey, products]);
return (
<div
className={`${styles.content} ${isTransitioning ? styles.transitioning : ''}`}
aria-live="polite"
>
<ProductGrid products={displayedProducts} />
</div>
);
}
CollectionContent.module.css
.content {
transition: opacity 0.2s ease;
}
.content.transitioning {
opacity: 0.5;
pointer-events: none;
}

Route-Based Transitions with View Transitions API

Modern browsers support the View Transitions API for smooth page transitions. Here’s how to use it in a Shopify context:

src/lib/navigation.ts
/**
* Navigate to a URL with a view transition if supported.
* Falls back to standard navigation in older browsers.
*/
export function navigateWithTransition(url: string) {
// Check for View Transitions API support
if (!document.startViewTransition) {
window.location.href = url;
return;
}
document.startViewTransition(() => {
window.location.href = url;
});
}
/**
* For SPA-like navigation within React components
*/
export function softNavigate(
url: string,
updateContent: () => void | Promise<void>
) {
if (!document.startViewTransition) {
updateContent();
return;
}
document.startViewTransition(async () => {
await updateContent();
});
}
/* Global view transition styles */
@view-transition {
navigation: auto;
}
/* Customize the transition */
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-in;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Named view transitions for specific elements */
.product-image {
view-transition-name: product-hero;
}
::view-transition-group(product-hero) {
animation-duration: 0.3s;
}

Optimistic Updates

Don’t wait for the server to respond before updating the UI. Show the expected result immediately, then reconcile if needed:

src/hooks/useCart.ts
import { create } from 'zustand';
import { cartApi } from '@/lib/shopify-api';
interface CartItem {
id: string;
variantId: string;
quantity: number;
// ... other properties
}
interface CartStore {
items: CartItem[];
isLoading: boolean;
error: string | null;
addItem: (variantId: string, quantity: number) => Promise<void>;
updateQuantity: (itemId: string, quantity: number) => Promise<void>;
removeItem: (itemId: string) => Promise<void>;
}
export const useCart = create<CartStore>((set, get) => ({
items: [],
isLoading: false,
error: null,
addItem: async (variantId, quantity) => {
// Optimistic update: add item immediately
const tempId = `temp-${Date.now()}`;
const optimisticItem: CartItem = {
id: tempId,
variantId,
quantity,
// Add other required properties
};
set((state) => ({
items: [...state.items, optimisticItem],
error: null,
}));
try {
// Actual API call
const response = await cartApi.addItem(variantId, quantity);
// Replace optimistic item with real item from server
set((state) => ({
items: state.items.map((item) =>
item.id === tempId ? response.item : item
),
}));
} catch (error) {
// Rollback: remove the optimistic item
set((state) => ({
items: state.items.filter((item) => item.id !== tempId),
error: 'Failed to add item. Please try again.',
}));
throw error;
}
},
updateQuantity: async (itemId, quantity) => {
// Store previous state for rollback
const previousItems = get().items;
// Optimistic update
set((state) => ({
items: state.items.map((item) =>
item.id === itemId ? { ...item, quantity } : item
),
}));
try {
await cartApi.updateQuantity(itemId, quantity);
} catch (error) {
// Rollback to previous state
set({ items: previousItems, error: 'Failed to update quantity.' });
throw error;
}
},
removeItem: async (itemId) => {
const previousItems = get().items;
// Optimistic: remove immediately
set((state) => ({
items: state.items.filter((item) => item.id !== itemId),
}));
try {
await cartApi.removeItem(itemId);
} catch (error) {
// Rollback
set({ items: previousItems, error: 'Failed to remove item.' });
throw error;
}
},
}));

Error State Recovery

When operations fail, provide clear feedback and recovery options:

src/components/ui/ErrorBoundary/LoadingError.tsx
import styles from './LoadingError.module.css';
interface LoadingErrorProps {
message?: string;
onRetry?: () => void;
}
export function LoadingError({
message = 'Something went wrong',
onRetry,
}: LoadingErrorProps) {
return (
<div className={styles.container} role="alert">
<div className={styles.icon}>
<AlertIcon />
</div>
<p className={styles.message}>{message}</p>
{onRetry && (
<button className={styles.retryButton} onClick={onRetry}>
Try Again
</button>
)}
</div>
);
}

Integrate with data fetching:

src/components/collection/CollectionPage.tsx
import { useState, useEffect } from 'react';
import { ProductGrid } from './ProductGrid';
import { LoadingError } from '@/components/ui/ErrorBoundary/LoadingError';
export function CollectionPage({ collectionHandle }) {
const [products, setProducts] = useState<Product[] | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchProducts = async () => {
setError(null);
setProducts(null); // Triggers skeleton
try {
const data = await fetchCollectionProducts(collectionHandle);
setProducts(data);
} catch (err) {
setError('Unable to load products. Please check your connection.');
}
};
useEffect(() => {
fetchProducts();
}, [collectionHandle]);
if (error) {
return <LoadingError message={error} onRetry={fetchProducts} />;
}
return <ProductGrid products={products} isLoading={products === null} />;
}

Progress Indicators for Long Operations

For operations that take more than 2-3 seconds, show progress:

src/components/ui/ProgressBar/ProgressBar.tsx
import styles from './ProgressBar.module.css';
interface ProgressBarProps {
progress: number; // 0-100
label?: string;
showPercentage?: boolean;
}
export function ProgressBar({
progress,
label,
showPercentage = true,
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className={styles.container} role="progressbar" aria-valuenow={clampedProgress}>
{label && <span className={styles.label}>{label}</span>}
<div className={styles.track}>
<div
className={styles.fill}
style={{ width: `${clampedProgress}%` }}
/>
</div>
{showPercentage && (
<span className={styles.percentage}>{Math.round(clampedProgress)}%</span>
)}
</div>
);
}
ProgressBar.module.css
.container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.track {
flex: 1;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.fill {
height: 100%;
background: var(--color-primary);
border-radius: 2px;
transition: width 0.3s ease;
}
.label,
.percentage {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.percentage {
min-width: 3ch;
text-align: right;
}

Implementing Reduced Motion

Always respect user preferences for reduced motion:

src/hooks/useReducedMotion.ts
import { useState, useEffect } from 'react';
/**
* Hook to detect user's reduced motion preference.
* Returns true if user prefers reduced motion.
*/
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return prefersReducedMotion;
}

Use in components:

src/components/collection/ProductGrid.tsx
import { useReducedMotion } from '@/hooks/useReducedMotion';
export function ProductGrid({ products }) {
const reducedMotion = useReducedMotion();
return (
<div className={styles.grid}>
{products.map((product, index) => (
<div
key={product.id}
className={styles.gridItem}
style={{
'--animation-delay': reducedMotion ? '0ms' : `${index * 50}ms`,
} as React.CSSProperties}
>
<ProductCard product={product} />
</div>
))}
</div>
);
}

Key Takeaways

  1. Skeleton screens > spinners: Show the shape of content, not just that something is loading.

  2. Immediate feedback: Users should see a response within 100ms of any action.

  3. Optimistic updates: Update the UI immediately, reconcile with the server in the background.

  4. Graceful degradation: Have fallbacks for errors and slow connections.

  5. Progress for long waits: If something takes more than 2-3 seconds, show progress.

  6. Respect reduced motion: Always check prefers-reduced-motion and provide alternatives.

  7. Stagger animations: Animate list items sequentially for a polished feel, but cap the effect for long lists.

  8. View Transitions API: Use it for smooth page-to-page transitions in supporting browsers.

In the next lesson, we’ll focus on micro-interactions—the small animations on buttons, inputs, and feedback elements that make your theme feel responsive and polished.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...