E-Commerce Product Page with Tailwind: Gallery, Options, Buy
Build a production-ready e-commerce product page with Tailwind CSS: image gallery, color/size pickers, sticky buy panel, and accessible cart UX — all without a CSS file.
Why Product Pages Are Harder Than They Look
Honestly, most tutorials show you a hero section and call it a day. Product pages are a different beast. You've got an image gallery with thumbnails, a sticky buy panel, color swatches, size selectors with out-of-stock states, quantity inputs, breadcrumbs, trust badges, and a review summary — all on one screen. It's a lot.
Tailwind CSS v4.0.2 makes this manageable. Not because utility classes are magic, but because the constraint of naming things in a design system forces you to be explicit about spacing, color, and state. You stop winging it.
This guide walks through each piece of a real product page layout. We'll cover the gallery grid, the variant selectors, and the sticky sidebar buy panel. By the end you'll have a component you can drop into any React app. If you're coming from a CSS Modules background, the tradeoffs are worth understanding before committing to this approach.
Page Layout: The Two-Column Split
The standard product page split is a 60/40 grid — image gallery on the left, buy panel on the right. On mobile you stack them vertically. Tailwind's lg:grid-cols-5 with lg:col-span-3 and lg:col-span-2 gives you this without a single media query in a CSS file.
Here's the outer shell. Notice the gap-x-12 — that's 48px of breathing room between the gallery and the panel, which matters a lot on wider viewports.
export function ProductLayout({ children }: { children: React.ReactNode }) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-x-12 gap-y-8">
{children}
</div>
</div>
);
}
export function GalleryColumn({ children }: { children: React.ReactNode }) {
return (
<div className="lg:col-span-3">
{children}
</div>
);
}
export function BuyColumn({ children }: { children: React.ReactNode }) {
return (
<div className="lg:col-span-2">
<div className="sticky top-24 space-y-6">
{children}
</div>
</div>
);
}The sticky top-24 on the buy panel is the piece most people forget. As the user scrolls through a long description below the fold, the price and add-to-cart button stay visible. That's a direct conversion lever, not a cosmetic choice.
Image Gallery with Thumbnail Strip
A product gallery needs a large featured image and a row of thumbnail images below. Clicking a thumbnail swaps the main image. It sounds simple, but you need to handle aspect ratios, active states, and hover states cleanly.
Use aspect-square on the main image wrapper to lock it to a 1:1 ratio regardless of the source image dimensions. For thumbnails, aspect-square w-16 gives you consistent 64px squares. The active thumbnail gets a ring-2 ring-offset-2 ring-black dark:ring-white treatment — that's a 2px ring with 2px gap, high contrast in both themes.
type GalleryProps = {
images: { src: string; alt: string }[];
};
export function ProductGallery({ images }: GalleryProps) {
const [active, setActive] = React.useState(0);
return (
<div className="flex flex-col gap-4">
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-zinc-100">
<img
src={images[active].src}
alt={images[active].alt}
className="h-full w-full object-cover object-center"
/>
</div>
<div className="flex gap-3 overflow-x-auto pb-1">
{images.map((img, i) => (
<button
key={i}
onClick={() => setActive(i)}
className={[
"aspect-square w-16 shrink-0 overflow-hidden rounded-lg bg-zinc-100",
i === active
? "ring-2 ring-offset-2 ring-black dark:ring-white"
: "opacity-60 hover:opacity-100 transition-opacity",
].join(" ")}
>
<img src={img.src} alt={img.alt} className="h-full w-full object-cover" />
</button>
))}
</div>
</div>
);
}One thing worth noting: the thumbnail row uses overflow-x-auto so it scrolls horizontally on mobile without breaking the layout. Add scrollbar-hide from a Tailwind plugin if you want to strip the scrollbar visually.
Color Swatches and Size Selectors
Variant selectors are where most implementations get sloppy. Color swatches need hover and selected states, but they also need to communicate out-of-stock without being confusing. Size buttons need the same — a disabled size should be visually crossed out, not just grayed out.
For colors, render a <button> per option with an inline backgroundColor style. The selected state gets ring-2 ring-offset-2 in a contrasting color. For out-of-stock items, layer a pseudo-element diagonal line using before: utilities or just a CSS variable. With Tailwind v4's new features you can reference CSS variables directly in utility values, which makes dynamic colors much cleaner.
type ColorOption = { name: string; hex: string; available: boolean };
export function ColorPicker({
options,
value,
onChange,
}: {
options: ColorOption[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Color: <span className="font-semibold text-zinc-900 dark:text-white">{value}</span>
</p>
<div className="flex gap-2 flex-wrap">
{options.map((opt) => (
<button
key={opt.name}
title={opt.name}
disabled={!opt.available}
onClick={() => onChange(opt.name)}
style={{ backgroundColor: opt.hex }}
className={[
"h-8 w-8 rounded-full border border-black/10 transition",
value === opt.name ? "ring-2 ring-offset-2 ring-black dark:ring-white" : "",
!opt.available ? "opacity-30 cursor-not-allowed" : "cursor-pointer hover:scale-110",
].join(" ")}
/>
))}
</div>
</div>
);
}Size buttons follow the same pattern but use text labels. Add aria-pressed on the selected size so screen readers understand the state. Don't rely on visual cues alone — keyboard and screen-reader users are real customers too.
The Sticky Buy Panel: Price, Quantity, Add to Cart
The buy panel is where the money is. Literally. It needs to show the current price (with sale price logic), a quantity stepper, the add-to-cart button, and maybe a few trust signals. All of it needs to feel snappy.
Price display is worth getting right. A sale price needs the old price struck through with line-through text-zinc-400 and the new price in something that reads as urgent — text-red-600 works well and doesn't need a design token for a simple pattern like this. If you're building a full design system, check out how Tailwind component patterns handles token-based color scales.
The quantity stepper is three elements: a minus button, an input, and a plus button. Wrap them in a flex items-center rounded-lg border border-zinc-200 container. The input itself should be w-12 text-center text-sm focus:outline-none — no border, just inherits from the wrapper. This avoids double-border issues and looks intentional.
The add-to-cart button should be full width, py-3.5, and have a loading state. When loading, disable the button and show a spinner. An aria-busy attribute communicates the state to assistive tech. Use disabled:opacity-60 disabled:cursor-not-allowed rather than conditionally removing the button — that way the layout doesn't shift.
Glassmorphism Trust Badges and the Visual Hierarchy
Below the add-to-cart button you want trust signals: free shipping threshold, return policy, secure checkout. These don't need to be flashy but they should look considered, not like an afterthought div with a border.
A subtle glassmorphism card works well here — bg-white/60 dark:bg-zinc-800/60 backdrop-blur-sm border border-white/20 rounded-xl p-4. That rgba(255,255,255,0.60) background with blur reads as a light, airy card without stealing focus from the buy button. If you want to understand the technique more deeply, what is glassmorphism covers the visual principles and browser support caveats.
The icons for each trust badge should be 20px (w-5 h-5) and use text-emerald-600 or text-blue-500 depending on your brand. Keep the label text at text-sm and the description at text-xs text-zinc-500. That hierarchy — icon, label, description — reads fast when the user is scanning.
Dark Mode, Theming, and oklch Colors
Product pages spend a lot of time showing photography. Dark mode changes the background but you don't want the images to look washed out or overly contrasty. A dark:bg-zinc-950 page background with dark:bg-zinc-900 card surfaces gives you depth without being harsh.
For the accent colors — the selected ring, the CTA button, the sale price — consider oklch color values if you're on Tailwind v4.0.2. The perceptual uniformity of oklch means your red sale price and your green in-stock badge have the same perceived brightness, which looks intentional rather than accidental. Something like oklch(55% 0.22 27) for red and oklch(55% 0.18 145) for green.
If your store supports a theme toggle, wire it to a <html class='dark'> swap rather than a CSS media query override. That way users can pick their preference independently of the OS setting. Building a theme toggle in React covers the localStorage persistence pattern that makes this work across page loads without a flash.
One real question worth asking: should your product page even have a dark mode? Some brands deliberately disable it because product photography looks different across modes and they can't control both. That's a valid call. The Tailwind setup doesn't force you either way.
Mobile Layout, Touch Targets, and Performance
On mobile the two-column grid stacks. Gallery first, buy panel below. That's fine, but the buy panel needs a mobile sticky bar at the bottom — fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-zinc-900 border-t border-zinc-200 p-4 with just the price and the add-to-cart button. This is standard e-commerce UX and you should implement it.
Touch targets are the thing that kills mobile conversions silently. The size buttons need to be at least 44px tall (h-11). The minus and plus buttons in the quantity stepper need h-10 w-10 minimum. Color swatches at h-8 w-8 are borderline — bump them to h-10 w-10 on touch devices using sm:h-8 sm:w-8.
For performance, lazy-load all gallery images except the first one. Use loading='eager' on the main featured image and loading='lazy' on thumbnails. If you're using Next.js, the <Image> component handles this plus WebP conversion automatically. Don't ship a 2MB JPEG as the hero — resize to 800px wide maximum for the main slot, 120px for thumbnails.
FAQ
Use sticky top-24 on the buy panel column rather than fixed. This keeps it in document flow so it naturally stops at the parent container's end. You only need fixed for the mobile sticky bottom bar, and there you handle the footer collision by padding the page bottom by the bar's height (typically pb-24).
Render the size button as disabled with aria-disabled='true' and apply opacity-40 cursor-not-allowed line-through classes. The line-through is important — color alone doesn't communicate unavailability to users with low color perception. You can also add a title attribute like 'Size XL — out of stock' for hover tooltips.
Yes. Wrap the main image in a container and toggle a key prop on the <img> when the active index changes — React will unmount and remount it. Then use Tailwind's animate-fadeIn (or a custom keyframe in your config) to fade in the new image. With the View Transitions API (available in Chrome 111+) you can also use document.startViewTransition() for a native cross-fade.
Register the color field with register('color') and use a controlled component pattern. Pass value={watch('color')} and onChange={(v) => setValue('color', v)} to your ColorPicker component. Add a required validation rule and render a <p>{errors.color?.message}</p> below the picker. This integrates cleanly without any custom controller wrappers.
Use object-cover inside a fixed aspect-square container. The image fills the box and crops. If cropping is unacceptable (e.g., packaging labels), use object-contain with a background color — bg-zinc-100 dark:bg-zinc-800 — so the letterbox area matches the page. For thumbnails, object-cover is almost always correct.
Apply overflow-hidden to the container and transition-transform duration-300 group-hover:scale-110 to the <img>. Put group on the container div. That's it — pure Tailwind, no JavaScript, GPU-composited transform for smooth 60fps animation. For a true pan-on-hover zoom you'll need a bit of JS to track mouse position and update transform-origin.