EmpireUI
Get Pro
← Blog9 min read#tailwind#ecommerce#product page

E-Commerce Product Page in Tailwind: Gallery, Options, CTA

Build a production-ready e-commerce product page with Tailwind CSS — image gallery, variant selectors, sticky CTA, and mobile layout that actually converts.

Modern e-commerce product page layout with gallery and options panel

What You're Actually Building

Most Tailwind e-commerce tutorials give you a card with a price tag and call it done. That's not a product page. A product page is a conversion machine — it's the last UI your customer sees before they hand over money, and every pixel matters.

Here's what we're covering: a two-column desktop layout with a sticky image gallery on the left, a scrollable details panel on the right, size/color variant selectors, an add-to-cart CTA with quantity input, and a mobile layout that collapses sensibly. Nothing fancy for the sake of it.

Honestly, the hardest part isn't the CSS. It's the structural decisions — what goes above the fold, how the sticky behavior works on different viewports, how variant state flows through the component. We'll handle all of it.

The stack is React 19 with Tailwind v4. If you're on Tailwind v3 most of this still works; you'll just swap the new @theme syntax for a regular config file. Worth noting: v4's first-party variant nesting makes the hover/active states on the gallery thumbnails cleaner than anything you could do before.

If you want pre-built UI primitives to drop into this layout, browse components at Empire UI — there's a lot that slots right in.

Page Structure and Two-Column Layout

Start with the outer shell. On desktop you want two columns, roughly 55% gallery / 45% details. On mobile those stack. lg:grid-cols-[55fr_45fr] gets you there with Tailwind v4's arbitrary-value grid syntax.

<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
  <div className="grid grid-cols-1 lg:grid-cols-[55fr_45fr] gap-x-12 gap-y-8">
    <GalleryPanel />
    <DetailsPanel />
  </div>
</main>

The gap-x-12 gives you 48px of breathing room between the panels on desktop. That might feel like a lot but at 1280px viewport it reads as intentional whitespace, not awkward dead space. Less than 32px and the two panels start competing visually.

Quick aside: max-w-7xl is 80rem / 1280px by default. If your brand runs wide (think full-bleed editorial sites), swap to max-w-screen-2xl and adjust the column ratio. Most standard e-com shops are fine at 7xl.

You'll want overflow-hidden on the outer container if you're doing any image zoom effects later — otherwise the zoomed image bleeds past the grid boundary on Safari 17 and older.

Building the Image Gallery

The gallery has two parts: a large main image and a row of thumbnails. The main image should be sticky on desktop so it stays visible as the user scrolls through long product descriptions. sticky top-6 handles that with 24px offset from the viewport top.

function GalleryPanel({ images }) {
  const [active, setActive] = useState(0);

  return (
    <div className="flex flex-col gap-4 lg:sticky lg:top-6 lg:self-start">
      {/* Main image */}
      <div className="aspect-square overflow-hidden rounded-2xl bg-gray-100">
        <img
          src={images[active].src}
          alt={images[active].alt}
          className="h-full w-full object-cover transition-opacity duration-300"
        />
      </div>

      {/* Thumbnails */}
      <div className="grid grid-cols-5 gap-2">
        {images.map((img, i) => (
          <button
            key={i}
            onClick={() => setActive(i)}
            className={`aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
              i === active
                ? 'border-black'
                : 'border-transparent hover:border-gray-300'
            }`}
          >
            <img src={img.src} alt={img.alt} className="h-full w-full object-cover" />
          </button>
        ))}
      </div>
    </div>
  );
}

The lg:self-start is not optional — without it, a sticky child inside a tall grid cell just scrolls normally because the grid stretches it to full column height. self-start collapses the sticky element to its intrinsic height so sticky actually fires.

Look, I've seen this bug waste two hours on client projects. Write it down somewhere.

For the thumbnail ring, border-2 border-black on the active state is intentional — a 2px ring is the minimum that registers visually at small sizes. At 1px it disappears on retina displays and feels broken. You can swap the ring color to match your brand's accent without any issues.

Want something more polished? The glassmorphism components at Empire UI include a frosted-glass image panel that layers beautifully over lifestyle product photos if your brand leans that direction.

Variant Selectors: Size and Color

Variant selectors are where a lot of implementations fall apart. You end up with a tangled mess of string comparisons and conditional classNames. Keep the state flat — one selectedSize string, one selectedColor string, both lifted into the parent product component.

function SizeSelector({ sizes, selected, onChange }) {
  return (
    <div className="flex flex-wrap gap-2">
      {sizes.map((size) => (
        <button
          key={size.value}
          disabled={!size.available}
          onClick={() => onChange(size.value)}
          className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all
            ${ size.value === selected
              ? 'border-black bg-black text-white'
              : 'border-gray-200 bg-white text-gray-900 hover:border-gray-400'
            }
            ${ !size.available ? 'opacity-40 cursor-not-allowed line-through' : '' }
          `}
        >
          {size.label}
        </button>
      ))}
    </div>
  );
}

The strikethrough on unavailable sizes (line-through) is a small thing that makes a big difference. Users scan size grids fast. A grayed-out button without visual text decoration looks like a hover state, not a sold-out indicator. Add both opacity-40 and line-through together.

For color swatches, 32px circles with a 2px white ring + 2px color border on the selected state is the pattern that reads cleanly at every viewport. That's w-8 h-8 rounded-full ring-2 ring-white ring-offset-2 on the selected color button.

function ColorSelector({ colors, selected, onChange }) {
  return (
    <div className="flex gap-3">
      {colors.map((color) => (
        <button
          key={color.value}
          title={color.label}
          onClick={() => onChange(color.value)}
          style={{ backgroundColor: color.hex }}
          className={`w-8 h-8 rounded-full border border-gray-200 transition-all
            ${ color.value === selected ? 'ring-2 ring-black ring-offset-2' : 'hover:scale-110' }
          `}
        />
      ))}
    </div>
  );
}

In practice, you'll want aria-label and aria-pressed on these buttons too. Color names mean nothing to screen readers and an <input type="radio"> group is semantically better than a bunch of loose buttons — but that's a full accessibility rabbit hole. Check out the wcag-accessibility-guide if you're going down that path.

Quantity Input and Add-to-Cart CTA

The CTA section is small but loaded with decisions. You need a quantity stepper, the main add-to-cart button, and usually a secondary action (save to wishlist, buy now). Stack them vertically with 12px gaps and a 16px gap before the button group.

function CartControls({ onAddToCart, onBuyNow }) {
  const [qty, setQty] = useState(1);

  return (
    <div className="flex flex-col gap-4">
      {/* Quantity stepper */}
      <div className="flex items-center gap-3">
        <button
          onClick={() => setQty(Math.max(1, qty - 1))}
          className="w-9 h-9 rounded-lg border border-gray-200 flex items-center justify-center hover:bg-gray-100 transition-colors text-lg"
        >
          −
        </button>
        <span className="w-8 text-center font-medium tabular-nums">{qty}</span>
        <button
          onClick={() => setQty(Math.min(99, qty + 1))}
          className="w-9 h-9 rounded-lg border border-gray-200 flex items-center justify-center hover:bg-gray-100 transition-colors text-lg"
        >
          +
        </button>
      </div>

      {/* Buttons */}
      <button
        onClick={() => onAddToCart(qty)}
        className="w-full py-4 bg-black text-white font-semibold rounded-xl hover:bg-gray-900 active:scale-[0.98] transition-all"
      >
        Add to Cart
      </button>
      <button
        onClick={() => onBuyNow(qty)}
        className="w-full py-4 border-2 border-black text-black font-semibold rounded-xl hover:bg-gray-50 active:scale-[0.98] transition-all"
      >
        Buy Now
      </button>
    </div>
  );
}

The active:scale-[0.98] on both buttons is a 2% press-down effect. At 2% it's subtle enough not to distract but tactile enough to confirm the click — particularly useful on mobile where there's no hover state to prime the interaction.

Cap your quantity at 99. Not for technical reasons, but because an input that lets you type 10,000 units into a consumer product page looks unfinished. Handle bulk orders separately.

One more thing — don't put the wishlist icon inside the add-to-cart button as a sibling span. It always ends up with alignment issues across browsers. Put it as a standalone icon button to the right of the row, 40×40px minimum tap target.

For the button styling, you can go well beyond flat black. Empire UI's gradient generator is great for generating an on-brand button background that still has solid contrast on the text.

Mobile Layout and Sticky CTA Footer

On mobile, the two-column grid collapses to a single column: gallery on top, details below. That's the default behavior you already get from grid-cols-1. What you need to add is a sticky bottom CTA bar so the add-to-cart is always reachable without scrolling back up.

{/* Sticky mobile CTA — hidden on lg+ */}
<div className="fixed bottom-0 inset-x-0 z-50 lg:hidden bg-white border-t border-gray-100 px-4 py-3 flex gap-3">
  <button className="flex-1 py-3.5 bg-black text-white font-semibold rounded-xl text-sm">
    Add to Cart — ${price}
  </button>
  <button className="w-12 h-12 rounded-xl border border-gray-200 flex items-center justify-center shrink-0">
    <HeartIcon className="w-5 h-5" />
  </button>
</div>

{/* Compensate for fixed footer height */}
<div className="h-20 lg:hidden" />

The h-20 spacer at the bottom of your page content is critical. Without it, the last 80px of your product description or reviews section hides behind the fixed bar. Easy to miss in dev because you're usually scrolled up.

That said, test the sticky bar on iPhone Safari 16+. The dynamic viewport height changes as the browser chrome collapses while scrolling, which can cause a brief flash where your fixed bar shifts. Adding env(safe-area-inset-bottom) to the bottom padding handles it: pb-[calc(0.75rem+env(safe-area-inset-bottom))].

On the gallery, mobile users expect horizontal swipe between images, not tap-on-thumbnails. That's a separate scroll-snap implementation worth doing if you have the time — check css-scroll-snap for the pattern.

Product Details: Pricing, Rating, and Description

The details panel has a predictable hierarchy: brand name (small, muted), product title (large, prominent), rating + review count, price with sale price handling, then a separator before the variant selectors.

function DetailsHeader({ product }) {
  const hasDiscount = product.salePrice && product.salePrice < product.price;

  return (
    <div className="flex flex-col gap-3">
      <p className="text-sm font-medium text-gray-500 uppercase tracking-widest">
        {product.brand}
      </p>
      <h1 className="text-3xl font-bold text-gray-900 leading-tight">
        {product.name}
      </h1>

      {/* Rating */}
      <div className="flex items-center gap-2">
        <StarRating value={product.rating} />
        <span className="text-sm text-gray-500">({product.reviewCount} reviews)</span>
      </div>

      {/* Pricing */}
      <div className="flex items-baseline gap-3">
        <span className="text-2xl font-bold">
          ${hasDiscount ? product.salePrice : product.price}
        </span>
        {hasDiscount && (
          <>
            <span className="text-lg text-gray-400 line-through">${product.price}</span>
            <span className="text-sm font-semibold text-emerald-600">
              Save {Math.round((1 - product.salePrice / product.price) * 100)}%
            </span>
          </>
        )}
      </div>

      <hr className="border-gray-100" />
    </div>
  );
}

The leading-tight on the H1 matters more than you'd think. Product names often run long — "Nike Air Max 2026 Premium Knit Breathable Running Shoe" — and loose line-height at text-3xl creates ugly gaps between the two wrapped lines.

Show the savings percentage, not just the sale price. "Save 23%" is more compelling than displaying two numbers and making the user do math. People are lazy. Give them the shortcut.

For the product description, a prose class from @tailwindcss/typography is the fastest path if your descriptions come from a CMS as HTML. If it's plain text, a whitespace-pre-line paragraph is fine. Don't roll your own markdown parser for this.

Descriptions longer than ~250 words should be truncated with a "Read more" toggle. Walls of text push the reviews section (social proof) below the fold on tablets. Keep the fold clean.

FAQ

How do I make the image gallery sticky without it breaking on mobile?

Use lg:sticky lg:top-6 lg:self-start — the lg: prefix means sticky only fires above the 1024px breakpoint. On mobile the gallery just flows normally in the single-column stack.

Should I use CSS Grid or Flexbox for the two-column product layout?

Grid. You need independent control over row and column sizing, and grid-cols-[55fr_45fr] gives you proportional columns without any flex-grow hacks. Flexbox works but you'll fight it.

What's the best way to handle out-of-stock variants in Tailwind?

Apply both opacity-40 and line-through to the button text, plus cursor-not-allowed and disabled on the element. Opacity alone looks like a hover state, not sold-out.

How do I add a sticky mobile CTA without it covering page content?

Add a spacer div at the bottom of your page content with the same height as your fixed bar — usually h-16 to h-20. Also add env(safe-area-inset-bottom) to the bar's bottom padding for iPhone notch support.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Building a Landing Page in Tailwind CSS: Section by SectionAvatar and Avatar Group in Tailwind: Initials, Stack, Count BadgeGlassmorphism E-Commerce: Product Pages with Frosted Glass CardsFooter Design in React: 5 Patterns From Minimal to Full-Featured