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.

Product Detail

Key points

  1. 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. 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. 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>
  )
}