How to build a modern product page with Alpine.js and Tailwind CSS
A hands-on walkthrough for building a responsive product page with Alpine.js and Tailwind CSS. Includes image gallery, color and size selectors, and accessible details sections.
Building the product page
Product pages need to be fast, interactive, and easy to use. Here’s how to build a modern product page with Alpine.js and Tailwind CSS—image gallery, color and size pickers, and accessible details sections included.
Prerequisites: Tailwind CSS and Alpine.js should already be set up in your project. Drop the snippets anywhere in your Astro or HTML files and they’ll just work.
1. Main Image Gallery with Thumbnails
The image gallery uses Alpine.js to manage which product image is currently shown. Alpine’s x-data
directive sets up a reactive state object with two properties:
images
: an array of image pathsactiveImage
: the index of the currently displayed image
When you click a thumbnail, Alpine updates activeImage
and the main image updates instantly. The x-for
directive loops through the images to render all thumbnails, and the :class
binding adds a ring to the active one.
<div
x-data="{
activeImage: 0,
images: [
'.../1.png',
'.../2.png',
'.../3.png',
'.../4.png',
'.../5.png',
'.../6.png'
]
}"
class="flex flex-col lg:sticky lg:top-24 lg:self-start gap-2"
>
<!-- Main Image -->
<div class="overflow-hidden aspect-square bg-zinc-200 rounded-2xl">
<img
:src="images[activeImage]"
class="object-cover size-full aspect-square"
alt="Product image"
/>
</div>
<!-- Thumbnails -->
<div class="w-full grid grid-cols-6 gap-2">
<template x-for="(image, index) in images" :key="index">
<button
@click="activeImage = index"
:class="{'ring-2 ring-zinc-900 ': activeImage === index}"
class="overflow-hidden size-full bg-zinc-200 rounded-xl aspect-square"
>
<img
:src="image"
class="object-cover size-full aspect-square"
alt="Product thumbnail"
/>
</button>
</template>
</div>
</div>
Alpine’s reactivity means the UI updates automatically when you interact with it—no manual DOM updates needed.
2. Color Selector
The color selector uses Alpine.js to keep track of which color is currently selected. The x-data
directive sets up a local state with an array of color options and an activeColor
property. Each color is rendered as a radio button using x-for
, and clicking a color updates activeColor
.
The :class
binding adds a ring to the selected color, giving instant visual feedback. Alpine’s reactivity means the UI updates as soon as you pick a new color—no page reloads or extra JavaScript required.
<div x-data="{ activeColor: null }">
<p class="text-xs uppercase text-zinc-500">Color</p>
<fieldset
aria-label="Choose a color"
class="mt-2"
x-data="{
colors: [
{ name: 'Black', ring: 'ring-zinc-700', bg: 'bg-zinc-400' },
{ name: 'Gray', ring: 'ring-zinc-300', bg: 'bg-zinc-200' },
{ name: 'Purple', ring: 'ring-purple-300', bg: 'bg-purple-200' },
{ name: 'Pink', ring: 'ring-pink-300', bg: 'bg-pink-200' },
{ name: 'Red', ring: 'ring-red-300', bg: 'bg-red-200' },
{ name: 'Orange', ring: 'ring-orange-300', bg: 'bg-orange-200' },
{ name: 'Yellow', ring: 'ring-yellow-300', bg: 'bg-yellow-200' },
{ name: 'Zinc', ring: 'ring-zinc-300', bg: 'bg-zinc-200' },
{ name: 'Cyan', ring: 'ring-cyan-300', bg: 'bg-cyan-200' },
{ name: 'Green', ring: 'ring-green-300', bg: 'bg-green-200' },
{ name: 'Teal', ring: 'ring-teal-300', bg: 'bg-teal-200' },
],
activeColor: null
}"
>
<div class="flex flex-wrap items-center gap-3">
<template x-for="color in colors" :key="color.name">
<label
:aria-label="color.name"
class="relative -m-0.5 duration-300 flex cursor-pointer items-center justify-center rounded-full p-0.5 focus:outline-none"
:class="{ 'ring-2 ring-offset-2 ring-zinc-900': activeColor === color.name }"
>
<input
type="radio"
name="color-choice"
:value="color.name"
class="sr-only"
@click="activeColor = color.name"
/>
<span
aria-hidden="true"
class="rounded-full size-6 ring-1"
:class="`${color.ring} ${color.bg}`"
></span>
</label>
</template>
</div>
</fieldset>
</div>
3. Size Chooser (US/EU)
The size chooser lets users pick their shoe size in either US or EU sizing systems. Alpine.js manages two pieces of state: sizeSystem
(which system is active) and activeSize
(the selected size). The sizes
object holds the available sizes for each system.
When you change the dropdown, Alpine updates sizeSystem
and resets the selected size. The size buttons are rendered with x-for
, and clicking a size updates activeSize
—the UI highlights the selected size automatically.
<div
x-data="{
sizeSystem: 'US',
activeSize: null,
sizes: {
US: ['6', '7', '8', '9', '10', '11', '12', '13'],
EU: ['39', '40', '41', '42', '43', '44', '45', '46']
}
}"
>
<div class="flex items-center justify-between">
<p class="text-xs uppercase text-zinc-500">Shoe size</p>
<div>
<label for="size-system" class="sr-only">Size System</label>
<select
id="size-system"
x-model="sizeSystem"
@change="activeSize = null"
class="block w-auto h-8 pl-2.5 pr-8 py-2 text-xs text-zinc-900 bg-white border rounded-lg ring-1 ring-zinc-200"
>
<option value="US">US</option>
<option value="EU">EU</option>
</select>
</div>
</div>
<div class="mt-2 grid grid-cols-4 gap-2 divide-x divide-zinc-200">
<template x-for="size in sizes[sizeSystem]" :key="size">
<div>
<input
type="radio"
:id="'size-' + size"
:value="size"
name="size-choice"
x-model="activeSize"
class="sr-only peer"
/>
<label
:for="'size-' + size"
x-text="size"
class="flex items-center justify-center px-3 py-2 text-sm font-medium bg-white cursor-pointer ring-1 ring-zinc-200 rounded-md duration-300 peer-checked:ring-2 peer-checked:ring-zinc-900 peer-checked:text-zinc-500 text-zinc-500 peer-checked:ring-offset-2"
></label>
</div>
</template>
</div>
</div>
Alpine’s reactivity keeps the UI in sync with the state—no extra code needed.
4. Accordion Sections for Details, Shipping, Returns
For the details, shipping, and returns sections, we use the native <details>
and <summary>
elements for accessibility. Alpine.js isn’t needed here because the browser handles the open/close state, but you can style the open state with Tailwind’s group-open
utilities for a smooth experience.
Each section expands or collapses when clicked, and you can add icons or transitions for extra polish.
<div class="mt-8 divide-y divide-zinc-200 border-y border-zinc-200">
<details class="cursor-pointer group">
<summary class="text-sm flex items-center justify-between w-full py-4 font-medium text-left select-none text-zinc-900 hover:text-zinc-500 focus:text-zinc-500">
Details
<svg ... class="icon icon-tabler-plus size-4 ..."></svg>
</summary>
<div class="pb-4">
<p class="text-sm text-zinc-500">This product is crafted from high-quality materials designed for durability and comfort. It features modern design elements and is perfect for everyday use.</p>
</div>
</details>
<details class="cursor-pointer group">
<summary class="text-sm flex items-center justify-between w-full py-4 font-medium text-left select-none text-zinc-900 hover:text-zinc-500 focus:text-zinc-500">
Shipping
<svg ... class="icon icon-tabler-plus size-4 ..."></svg>
</summary>
<div class="pb-4">
<p class="text-sm text-zinc-500">We offer free standard shipping on all orders above $50. Express shipping options are available at checkout for an additional fee.</p>
</div>
</details>
<details class="cursor-pointer group">
<summary class="text-sm flex items-center justify-between w-full py-4 font-medium text-left select-none text-zinc-900 hover:text-zinc-500 focus:text-zinc-500">
Returns
<svg ... class="icon icon-tabler-plus size-4 ..."></svg>
</summary>
<div class="pb-4">
<p class="text-sm text-zinc-500">We accept returns within 30 days of purchase. Items must be in their original condition and packaging. Please visit our returns page for more details.</p>
</div>
</details>
</div>
5. Putting It All Together
Alpine.js makes it easy to wire up interactive product pages without heavy JavaScript. Here’s a quick outline of how the pieces fit:
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Image Gallery -->
<div>...main image gallery code...</div>
<!-- Product Details -->
<div>
<h1>Product Name</h1>
<p>Price</p>
<p>Description</p>
<!-- Color Selector -->
...color selector code...
<!-- Size Chooser -->
...size chooser code...
<!-- Action Buttons -->
<button>Add to Cart</button>
<button>Buy Now</button>
<!-- Accordion Sections -->
...accordion code...
</div>
</div>
Tips
- Use Alpine.js
x-data
to create local state for each interactive section. - Bind data to your UI with
x-model
,x-for
, and@click
for instant reactivity. - Tailwind CSS utility classes make it easy to style and customize.
- Accordions are accessible and keyboard-friendly by default.
- Extend with more product options or sections as needed.
Alpine.js and Tailwind CSS let you build fast, interactive product pages with minimal code. The reactivity and declarative syntax keep your markup clean and your UI snappy.
/Michael Andreuzza