Collection Page Components Intermediate 12 min read

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 here

See Collection Page Architecture for the complete Liquid section setup and data serialization.

Data Source

Prop/StateSourceLiquid Field
productParent (ProductGrid)collection.products[n]
product.idLiquid JSONproduct.id
product.titleLiquid JSONproduct.title
product.handleLiquid JSONproduct.handle
product.urlLiquid JSONproduct.url
product.priceLiquid JSONproduct.price
product.compareAtPriceLiquid JSONproduct.compare_at_price
product.availableLiquid JSONproduct.available
product.featuredImageLiquid JSONproduct.featured_image
product.optionsLiquid JSONproduct.options_with_values
product.variantsLiquid JSONproduct.variants
priorityParent prop- (passed for LCP optimization)
selectedColorLocal state-
isHoveredLocal state-

Product Card Anatomy

┌───────────────────────────────────┐
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ Product Image │ │
│ │ (hover shows 2nd image) │ │
│ │ │ │
│ │ [SALE] [♡] │ │
│ └─────────────────────────────┘ │
│ │
│ ○ ○ ○ ○ (color swatches) │
│ │
│ Product Title │
│ $49.00 $69.00 │
│ │
└───────────────────────────────────┘

The Product Card Component

src/components/collection/ProductCard/ProductCard.tsx
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

src/components/collection/ProductCard/ProductCard.module.css
.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

src/components/collection/ProductCard/ProductCardImage.tsx
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>
);
}
src/components/collection/ProductCard/ProductCardImage.module.css
.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

src/components/collection/ProductCard/ColorSwatches.tsx
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.
}
src/components/collection/ProductCard/ColorSwatches.module.css
.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

src/components/collection/ProductCard/WishlistButton.tsx
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>
);
}
src/components/collection/ProductCard/WishlistButton.module.css
.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:

src/components/collection/ProductCard/QuickAddButton.tsx
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

  1. Image hover effect: Show secondary image on hover for visual interest
  2. Color swatches: Let users preview colors without leaving the grid
  3. Badges: Highlight sale items and sold-out products
  4. Wishlist: Simple localStorage-based wishlist functionality
  5. Quick add: One-click add for single-variant products
  6. Lazy loading: Only load images as they enter the viewport
  7. 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...