Product detail
Switch between photos, choose a color and quantity, and add it to the cart. What you can choose depends on how much is in stock.
Key points
- 1
Make the current selection visible
The photo you’re viewing gets a border, and the selected color is a black button. You can tell what’s selected at a glance.
- 2
Disable buttons that shouldn’t be pressed
Quantity can’t go below 1 or above the stock. Out-of-stock colors can’t be chosen. Disabling a button up front is clearer than showing an error after it’s pressed.
- 3
Show the result with a count and a notification
The cart count at the top goes up, and “Added N × item to cart” appears in the bottom-right corner. When the screen doesn’t change, show the result.
Tools used
Click to see the comparison in Part 2
Edit the code
Edit the code and the preview on the right updates.
import { useState } from "react" import { MinusIcon, PlusIcon, ShoppingCartIcon } from "lucide-react" import { toast } from "sonner" import { Button } from "./components/ui/button" import { Toaster } from "./components/ui/sonner" // Product data (in a real app, this comes from a server) const product = { name: "Wireless Earbuds", category: "Electronics", price: 85, rating: 4.5, reviews: 128, description: "Noise-cancelling earbuds that let you focus on your music. Up to 8 hours on a single charge.", // Colors in place of photos (4) images: ["#0ea5e9", "#0284c7", "#7dd3fc", "#e0f2fe"], // Stock for each color colors: [ { name: "Black", stock: 5 }, { name: "White", stock: 2 }, { name: "Blue", stock: 0 }, ], } export default function App() { // ① State the screen remembers //const imageIndex = 0 // Index of the large photo (0 to 3) const [imageIndex, setImageIndex] = useState(0) const [color, setColor] = useState("Black") // The selected color const [quantity, setQuantity] = useState(1) // Quantity const [cartCount, setCartCount] = useState(0) // Number of items in the cart const [colors, setColors] = useState(product.colors) // Stock for each color (goes down when added to the cart) // Stock for the selected color const stock = colors.find((c) => c.name === color)?.stock ?? 0 // When "Add to cart" is clicked function addToCart() { // Increase the cart count by the selected quantity setCartCount(cartCount + quantity) setColors(colors.map((c) => (c.name === color ? { ...c, stock: c.stock - quantity } : c))) setQuantity(1) toast.success(`Added ${quantity} × ${product.name} (${color}) to the cart`) } return ( <div className="mx-auto my-6 w-full max-w-3xl rounded-xl border bg-card text-card-foreground"> {/* Top bar: the shop name and the cart count */} <header className="flex items-center justify-between border-b px-6 py-3 text-sm"> <span className="font-semibold">Shop</span> {/* Right side: the cart count and "Empty cart" */} <div className="flex items-center gap-2"> <span className="flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1"> <ShoppingCartIcon className="size-4" /> Cart <b>{cartCount}</b> </span> <Button type="button" size="sm" variant="ghost" disabled={cartCount === 0} onClick={() => { setCartCount(0); setColors(product.colors) }}> Empty cart </Button> </div> </header> <div className="grid gap-8 p-6 md:grid-cols-2"> {/* Photos: one large and four small */} <div> <div className="aspect-square rounded-lg" style={{ backgroundColor: product.images[imageIndex] }} /> <div className="mt-3 grid grid-cols-4 gap-2"> {product.images.map((image, index) => ( <button key={image} type="button" className={imageIndex === index ? "aspect-square rounded-md ring-2 ring-foreground ring-offset-2" : "aspect-square rounded-md"} style={{ backgroundColor: image }} onClick={() => setImageIndex(index)} /> ))} </div> </div> {/* Product info */} <div> <p className="text-xs text-muted-foreground">{product.category}</p> <p className="mt-1 text-2xl font-semibold">{product.name}</p> <p className="mt-1 text-sm text-amber-500"> ★ {product.rating} <span className="text-muted-foreground">({product.reviews} reviews)</span> </p> <p className="mt-4 text-2xl font-semibold">${product.price.toLocaleString()}</p> <p className="mt-4 text-sm leading-relaxed text-muted-foreground">{product.description}</p> {/* Color */} <p className="mt-6 text-sm font-medium">Color</p> <div className="mt-2 flex flex-wrap gap-2"> {colors.map((option) => ( <Button key={option.name} type="button" size="sm" variant={color === option.name ? "default" : "outline"} disabled={option.stock === 0} /* Select this color on click; highlight the selected color; disable colors that are out of stock */ onClick={() => { setColor(option.name) setQuantity(1) }} > {option.name} </Button> ))} </div> {/* Quantity */} <p className="mt-5 text-sm font-medium">Quantity</p> <div className="mt-2 flex items-center gap-3"> <div className="inline-flex items-center rounded-md border"> <Button type="button" size="icon" variant="ghost" aria-label="Decrease by one" disabled={quantity <= 1} onClick={() => setQuantity(quantity - 1)} > <MinusIcon /> </Button> <span className="w-10 text-center text-sm">{quantity}</span> <Button type="button" size="icon" variant="ghost" aria-label="Increase by one" disabled={quantity >= stock} onClick={() => setQuantity(quantity + 1)} > <PlusIcon /> </Button> </div> <span className="text-xs text-muted-foreground">{stock} left</span> </div> <Button type="button" className="mt-6 w-full" disabled={stock === 0} onClick={addToCart}> Add to cart </Button> </div> </div> <Toaster position="bottom-right" /> </div> ) }