Product Card Component with Variants Preview
Build a product card component that displays product information, handles image hover effects, and shows variant options like color swatches.
Product cards are the building blocks of collection pages. A great product card shows essential information at a glance, provides visual feedback on hover, and offers quick access to variants. Let’s build one.
Theme Integration
This component is part of the collection page component hierarchy:
sections/collection-products.liquid└── <div id="collection-products-root"> └── CollectionPage (React) └── ProductGrid └── ProductCard ← You are hereSee Collection Page Architecture for the complete Liquid section setup and data serialization.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
product | Parent (ProductGrid) | collection.products[n] |
product.id | Liquid JSON | product.id |
product.title | Liquid JSON | product.title |
product.handle | Liquid JSON | product.handle |
product.url | Liquid JSON | product.url |
product.price | Liquid JSON | product.price |
product.compareAtPrice | Liquid JSON | product.compare_at_price |
product.available | Liquid JSON | product.available |
product.featuredImage | Liquid JSON | product.featured_image |
product.options | Liquid JSON | product.options_with_values |
product.variants | Liquid JSON | product.variants |
priority | Parent prop | - (passed for LCP optimization) |
selectedColor | Local state | - |
isHovered | Local state | - |
Product Card Anatomy
┌───────────────────────────────────┐│ ┌─────────────────────────────┐ ││ │ │ ││ │ Product Image │ ││ │ (hover shows 2nd image) │ ││ │ │ ││ │ [SALE] [♡] │ ││ └─────────────────────────────┘ ││ ││ ○ ○ ○ ○ (color swatches) ││ ││ Product Title ││ $49.00 $69.00 ││ │└───────────────────────────────────┘The Product Card Component
import { useState, useCallback } from 'react';import type { CollectionProduct } from '@/types/collection';import { formatMoney } from '@/utils/money'; // Utility to format prices.import { ProductCardImage } from './ProductCardImage';import { ColorSwatches } from './ColorSwatches';import { WishlistButton } from './WishlistButton';import styles from './ProductCard.module.css';
interface ProductCardProps { product: CollectionProduct; // Product data from the collection. priority?: boolean; // Whether to eagerly load images (for above-fold cards).}
/** * ProductCard component displays a single product in the collection grid. * Features: image hover effect, sale badges, color swatches, and wishlist button. */export function ProductCard({ product, priority = false }: ProductCardProps) { // Track selected color for swatch preview (changes displayed image). const [selectedColor, setSelectedColor] = useState<string | null>(null); // Track hover state for image swap effect. const [isHovered, setIsHovered] = useState(false);
// Calculate if product is on sale and discount percentage. const hasDiscount = product.compareAtPrice && product.compareAtPrice > product.price; const discountPercentage = hasDiscount ? Math.round((1 - product.price / product.compareAtPrice!) * 100) : 0;
// Find the "Color" option from product options (case-insensitive). const colorOption = product.options.find( (option) => option.name.toLowerCase() === 'color' );
// Determine which image to display based on selected color swatch. const displayImage = selectedColor ? getImageForColor(product, selectedColor) : product.featuredImage;
// Memoized hover handlers to prevent unnecessary re-renders. const handleMouseEnter = useCallback(() => setIsHovered(true), []); const handleMouseLeave = useCallback(() => setIsHovered(false), []);
return ( <article className={styles.card} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > <div className={styles.imageWrapper}> {/* Clickable image area linking to product page */} <a href={product.url} className={styles.imageLink}> <ProductCardImage primaryImage={displayImage} secondaryImage={product.images[1] || null} // Second image for hover effect. alt={product.title} isHovered={isHovered} priority={priority} // Eager load for above-fold images. /> </a>
{/* Badges: Sold Out and Sale indicators */} <div className={styles.badges}> {!product.available && ( <span className={`${styles.badge} ${styles.soldOut}`}>Sold Out</span> )} {product.available && hasDiscount && ( <span className={`${styles.badge} ${styles.sale}`}>-{discountPercentage}%</span> )} </div>
{/* Wishlist heart button - appears on hover */} <WishlistButton productId={product.id} className={styles.wishlist} /> </div>
{/* Color swatches - only show if product has multiple colors */} {colorOption && colorOption.values.length > 1 && ( <ColorSwatches colors={colorOption.values} selectedColor={selectedColor} onSelect={setSelectedColor} productHandle={product.handle} /> )}
{/* Product info: title and price */} <div className={styles.info}> <a href={product.url} className={styles.title}> {product.title} </a>
<div className={styles.price}> {/* Current price - styled differently if on sale */} <span className={hasDiscount ? styles.salePrice : ''}> {formatMoney(product.price)} </span> {/* Compare-at price (original price) shown with strikethrough */} {hasDiscount && ( <span className={styles.comparePrice}> {formatMoney(product.compareAtPrice!)} </span> )} </div> </div> </article> );}
/** * Helper function to get the image URL for a specific color variant. * This enables color swatch preview functionality. */function getImageForColor( product: CollectionProduct, color: string): CollectionProduct['featuredImage'] { // Find variant whose title contains the selected color. const variant = product.variants.find((variant) => variant.title.toLowerCase().includes(color.toLowerCase()) );
// In a full implementation, you'd return variant.image here. // This requires including variant images in your Liquid serialization. // For now, fall back to the featured image. return product.featuredImage;}Product Card Styles
.card { position: relative; display: flex; flex-direction: column; gap: 0.75rem;}
.imageWrapper { position: relative; aspect-ratio: 3 / 4; overflow: hidden; background: var(--color-background-subtle); border-radius: 8px;}
.imageLink { display: block; width: 100%; height: 100%;}
.badges { position: absolute; top: 0.75rem; left: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;}
.badge { padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.02em; border-radius: 4px;}
.soldOut { background: var(--color-text); color: var(--color-background);}
.sale { background: var(--color-error); color: white;}
.wishlist { position: absolute; top: 0.75rem; right: 0.75rem;}
.info { display: flex; flex-direction: column; gap: 0.375rem;}
.title { font-size: 0.9375rem; font-weight: 500; color: var(--color-text); text-decoration: none; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;}
.title:hover { text-decoration: underline;}
.price { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9375rem;}
.salePrice { color: var(--color-error); font-weight: 600;}
.comparePrice { color: var(--color-text-muted); text-decoration: line-through;}Product Card Image with Hover Effect
import { useState } from 'react';import styles from './ProductCardImage.module.css';
interface ProductCardImageProps { primaryImage: { url: string; alt: string; } | null; secondaryImage: { url: string; alt: string; } | null; alt: string; // Fallback alt text if image doesn't have one. isHovered: boolean; // Controls which image is visible. priority?: boolean; // Whether to eager load (for above-fold images).}
/** * ProductCardImage handles the image display with hover swap effect. * Shows primary image by default, swaps to secondary on hover. */export function ProductCardImage({ primaryImage, secondaryImage, alt, isHovered, priority = false,}: ProductCardImageProps) { // Track when primary image has loaded for fade-in effect. const [isLoaded, setIsLoaded] = useState(false); // Determine if secondary image should be visible. const showSecondary = isHovered && secondaryImage;
// Show placeholder if no primary image exists. if (!primaryImage) { return <div className={styles.placeholder} />; }
return ( <div className={styles.container}> {/* Primary image - always rendered, fades in when loaded */} <img src={primaryImage.url} alt={primaryImage.alt || alt} className={`${styles.image} ${styles.primary} ${isLoaded ? styles.loaded : ''}`} onLoad={() => setIsLoaded(true)} // Trigger fade-in animation. loading={priority ? 'eager' : 'lazy'} // Lazy load below-fold images. decoding={priority ? 'sync' : 'async'} // Sync decode for priority images. />
{/* Secondary image - only rendered if exists, shown on hover */} {secondaryImage && ( <img src={secondaryImage.url} alt={secondaryImage.alt || alt} className={`${styles.image} ${styles.secondary} ${showSecondary ? styles.visible : ''}`} loading="lazy" // Always lazy load secondary images. decoding="async" /> )} </div> );}.container { position: relative; width: 100%; height: 100%;}
.image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: opacity 0.3s ease, transform 0.3s ease;}
.primary { opacity: 0;}
.primary.loaded { opacity: 1;}
.secondary { opacity: 0;}
.secondary.visible { opacity: 1;}
.placeholder { width: 100%; height: 100%; background: var(--color-background-subtle);}
/* Subtle zoom on hover */.container:hover .image { transform: scale(1.03);}Color Swatches Component
import styles from './ColorSwatches.module.css';
interface ColorSwatchesProps { colors: string[]; // Array of color names from product options. selectedColor: string | null; // Currently selected color for preview. onSelect: (color: string | null) => void; // Callback when color is selected. productHandle: string; // Used for the "+X more" link. maxVisible?: number; // Maximum swatches to show before "+X more".}
/** * ColorSwatches displays clickable color options for a product. * Clicking a swatch previews that color's variant image. */export function ColorSwatches({ colors, selectedColor, onSelect, productHandle, maxVisible = 5, // Default to showing 5 swatches.}: ColorSwatchesProps) { // Limit visible swatches to prevent UI overflow. const visibleColors = colors.slice(0, maxVisible); const remainingCount = colors.length - maxVisible;
// Toggle selection - clicking selected color deselects it. const handleClick = (color: string) => { onSelect(selectedColor === color ? null : color); };
return ( <div className={styles.swatches}> {visibleColors.map((color) => ( <button key={color} type="button" className={`${styles.swatch} ${selectedColor === color ? styles.selected : ''}`} onClick={() => handleClick(color)} aria-label={`Select ${color}`} // Accessibility: describe the action. aria-pressed={selectedColor === color} // Accessibility: toggle state. > {/* Inner span displays the actual color */} <span className={styles.swatchColor} style={{ backgroundColor: getColorValue(color) }} /> </button> ))}
{/* Show "+X more" link if colors exceed maxVisible */} {remainingCount > 0 && ( <a href={`/products/${productHandle}`} className={styles.more}> +{remainingCount} </a> )} </div> );}
/** * Maps color names to CSS color values. * Handles common color names; unknown colors fall back to the name itself * (useful if the color name is already a valid CSS value). */function getColorValue(colorName: string): string { const colorMap: Record<string, string> = { black: '#000000', white: '#ffffff', red: '#dc2626', blue: '#2563eb', green: '#16a34a', yellow: '#eab308', orange: '#ea580c', purple: '#9333ea', pink: '#ec4899', gray: '#6b7280', grey: '#6b7280', navy: '#1e3a5f', beige: '#d4c4a8', brown: '#78350f', // Extend this map with your store's color naming conventions. };
// Normalize: lowercase and remove spaces (e.g., "Light Blue" -> "lightblue"). const normalized = colorName.toLowerCase().replace(/\s+/g, ''); return colorMap[normalized] || colorName; // Fallback to original name.}.swatches { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.25rem;}
.swatch { position: relative; width: 20px; height: 20px; padding: 2px; border: 1px solid var(--color-border); border-radius: 50%; background: transparent; cursor: pointer; transition: border-color 0.15s ease;}
.swatch:hover { border-color: var(--color-text);}
.swatch.selected { border-color: var(--color-text); border-width: 2px; padding: 1px;}
.swatchColor { display: block; width: 100%; height: 100%; border-radius: 50%;}
/* White swatch needs a visible border */.swatchColor[style*="ffffff"],.swatchColor[style*="white"] { box-shadow: inset 0 0 0 1px var(--color-border);}
.more { font-size: 0.75rem; color: var(--color-text-muted); text-decoration: none;}
.more:hover { color: var(--color-text);}Wishlist Button
import { useState } from 'react';import styles from './WishlistButton.module.css';
interface WishlistButtonProps { productId: number; // Product ID to track in wishlist. className?: string; // Additional CSS classes for positioning.}
/** * WishlistButton provides a simple localStorage-based wishlist functionality. * Persists across sessions without requiring user authentication. */export function WishlistButton({ productId, className = '' }: WishlistButtonProps) { // Initialize state from localStorage - lazy initialization. const [isWishlisted, setIsWishlisted] = useState(() => { // Check if this product is already in the wishlist. const wishlist = JSON.parse(localStorage.getItem('wishlist') || '[]'); return wishlist.includes(productId); });
// Toggle wishlist state and update localStorage. const toggleWishlist = () => { const wishlist = JSON.parse(localStorage.getItem('wishlist') || '[]');
if (isWishlisted) { // Remove from wishlist. const updated = wishlist.filter((id: number) => id !== productId); localStorage.setItem('wishlist', JSON.stringify(updated)); } else { // Add to wishlist. wishlist.push(productId); localStorage.setItem('wishlist', JSON.stringify(wishlist)); }
setIsWishlisted(!isWishlisted); // Toggle local state. };
return ( <button type="button" className={`${styles.button} ${className} ${isWishlisted ? styles.active : ''}`} onClick={toggleWishlist} aria-label={isWishlisted ? 'Remove from wishlist' : 'Add to wishlist'} aria-pressed={isWishlisted} // Accessibility: communicate toggle state. > <HeartIcon filled={isWishlisted} /> </button> );}
/** * Heart icon that changes between outline and filled states. */function HeartIcon({ filled }: { filled: boolean }) { return ( <svg width="20" height="20" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} // Filled when wishlisted. stroke="currentColor" strokeWidth="2" > <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> </svg> );}.button { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; border: none; border-radius: 50%; background: var(--color-background); color: var(--color-text); cursor: pointer; opacity: 0; transform: scale(0.9); transition: opacity 0.2s ease, transform 0.2s ease, color 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);}
/* Show on card hover */.card:hover .button { opacity: 1; transform: scale(1);}
/* Always show when active */.button.active { opacity: 1; transform: scale(1); color: var(--color-error);}
.button:hover { color: var(--color-error);}Quick Add Button (Optional)
For products without variants, add a quick add button:
import { useState } from 'react';import { useCart } from '@/stores/cart'; // Zustand cart store.import styles from './QuickAddButton.module.css';
interface QuickAddButtonProps { variantId: number; // The variant ID to add to cart. available: boolean; // Whether the variant is in stock. className?: string; // Additional CSS classes.}
/** * QuickAddButton enables one-click add-to-cart for single-variant products. * Shows loading state during add, and "Added!" confirmation briefly. */export function QuickAddButton({ variantId, available, className = '' }: QuickAddButtonProps) { const addItem = useCart((state) => state.addItem); // Cart store action. const [isAdding, setIsAdding] = useState(false); // Loading state. const [justAdded, setJustAdded] = useState(false); // Success feedback state.
const handleClick = async (event: React.MouseEvent) => { event.preventDefault(); // Prevent link navigation if inside anchor.
// Guard: don't add if unavailable or already adding. if (!available || isAdding) return;
setIsAdding(true);
try { await addItem(variantId, 1); // Add 1 unit to cart. setJustAdded(true); // Show success state. // Reset success state after 2 seconds. setTimeout(() => setJustAdded(false), 2000); } finally { setIsAdding(false); // Always reset loading state. } };
// Show disabled "Sold Out" state for unavailable products. if (!available) { return ( <span className={`${styles.button} ${styles.disabled} ${className}`}> Sold Out </span> ); }
return ( <button type="button" className={`${styles.button} ${className} ${justAdded ? styles.added : ''}`} onClick={handleClick} disabled={isAdding} // Disable during API call. > {/* Dynamic button text based on state */} {isAdding ? 'Adding...' : justAdded ? 'Added!' : 'Quick Add'} </button> );}Key Takeaways
- Image hover effect: Show secondary image on hover for visual interest
- Color swatches: Let users preview colors without leaving the grid
- Badges: Highlight sale items and sold-out products
- Wishlist: Simple localStorage-based wishlist functionality
- Quick add: One-click add for single-variant products
- Lazy loading: Only load images as they enter the viewport
- Accessible: Proper labels, focus states, and ARIA attributes
In the next lesson, we’ll build the Product Grid with responsive layouts.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...