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.

Published on October 6, 2025 by Michael Andreuzza

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.


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 paths
  • activeImage: 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

Did you like this post? Please share it with your friends!