Product pages are where conversions live or die. A static HTML layout gets you 80% of the way there, but that last 20% — the image switcher that reacts instantly, the variant selector that dims unavailable options, the specs tab that doesn't reload the page — is where Alpine.js earns its place. Paired with Tailwind CSS v4's CSS-first configuration, you can build something that feels genuinely polished without reaching for a JavaScript framework or a complex bundler setup.
This tutorial walks through building a realistic product page section by section. You'll end up with a working image gallery, a color/size variant selector, and a tabbed specs panel — all wired up with Alpine.js directives and styled with Tailwind v4 utility classes. The final code is copy-pasteable and works in any HTML-first environment, including Astro.
What we're building
A single product page with three interactive regions:
- A thumbnail gallery — clicking a thumbnail swaps the main image with a fade transition.
- A variant selector — color and size buttons that track state and update the displayed SKU.
- A tabbed specs panel — "Overview", "Specs", and "Shipping" tabs with no page reload.
Setup
Tailwind CSS v4
Tailwind v4 ships with a CSS-first configuration model. You no longer need a tailwind.config.js. Your design tokens live directly in your CSS file using the @theme directive:
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(0.55 0.22 265);
--color-surface: oklch(0.97 0 0);
--color-muted: oklch(0.60 0.01 240);
--radius-card: 0.75rem;
--radius-btn: 0.5rem;
}
Tailwind reads those --color-* and --radius-* variables and generates utility classes like bg-brand, text-muted, and rounded-card automatically. No extra config step.
Alpine.js
Drop Alpine in via CDN for zero-configuration interactivity. Add this before your closing </body> tag:
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
For a production Astro project, install it with npm install alpinejs and initialize it in your layout's client script instead.
Part 1: The image gallery
The gallery has one large "hero" image and a row of thumbnails. Clicking a thumbnail updates the hero image. We'll add a fade transition so the swap feels intentional rather than jarring.
<div
x-data="{
images: [
'/images/product-main.jpg',
'/images/product-side.jpg',
'/images/product-detail.jpg'
],
active: 0,
visible: true,
swap(index) {
if (this.active === index) return;
this.visible = false;
setTimeout(() => {
this.active = index;
this.visible = true;
}, 150);
}
}"
class="flex flex-col gap-4"
>
<!-- Hero image -->
<div class="overflow-hidden rounded-card bg-surface aspect-square">
<img
:src="images[active]"
alt="Product image"
x-show="visible"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="h-full w-full object-cover"
/>
</div>
<!-- Thumbnails -->
<div class="flex gap-3">
<template x-for="(img, i) in images" :key="i">
<button
@click="swap(i)"
:class="active === i
? 'ring-2 ring-brand ring-offset-2'
: 'opacity-60 hover:opacity-100'"
class="h-20 w-20 flex-shrink-0 overflow-hidden rounded-btn transition"
>
<img :src="img" alt="" class="h-full w-full object-cover" />
</button>
</template>
</div>
</div>
A few things worth noting here. The swap()active and shows the new image. This prevents Alpine from trying to transition an element whose src is already changing underneath it. The x-transition class-based syntax gives us precise control: enter starts at opacity-0 scale-95 and eases to full size, while leave does the reverse.
Part 2: Variant selector
Color and size selectors need to track two independent pieces of state and communicate with each other. Alpine's x-data object handles this cleanly — no external store needed for a single component.
<div
x-data="{
selectedColor: 'slate',
selectedSize: null,
colors: [
{ name: 'Slate', value: 'slate', hex: '#64748b' },
{ name: 'Indigo', value: 'indigo', hex: '#6366f1' },
{ name: 'Rose', value: 'rose', hex: '#f43f5e' }
],
sizes: ['XS', 'S', 'M', 'L', 'XL'],
get sku() {
return this.selectedSize
? \`TH-\${this.selectedColor.toUpperCase()}-\${this.selectedSize}\`
: null;
}
}"
class="flex flex-col gap-6"
>
<!-- Color picker -->
<div>
<p class="mb-2 text-sm font-medium">
Color: <span class="font-semibold" x-text="selectedColor"></span>
</p>
<div class="flex gap-2">
<template x-for="color in colors" :key="color.value">
<button
@click="selectedColor = color.value"
:title="color.name"
:style="\`background-color: \${color.hex}\`"
:class="selectedColor === color.value
? 'ring-2 ring-offset-2 ring-brand scale-110'
: 'hover:scale-105'"
class="h-8 w-8 rounded-full transition"
></button>
</template>
</div>
</div>
<!-- Size picker -->
<div>
<p class="mb-2 text-sm font-medium">Size</p>
<div class="flex flex-wrap gap-2">
<template x-for="size in sizes" :key="size">
<button
@click="selectedSize = size"
:class="selectedSize === size
? 'bg-brand text-white'
: 'bg-surface text-gray-700 hover:bg-gray-100'"
class="rounded-btn border px-4 py-2 text-sm font-medium transition"
x-text="size"
></button>
</template>
</div>
</div>
<!-- SKU display -->
<p x-show="sku" class="text-sm text-muted">
SKU: <span x-text="sku" class="font-mono"></span>
</p>
<!-- Add to cart -->
<button
:disabled="!selectedSize"
:class="selectedSize
? 'bg-brand text-white hover:opacity-90'
: 'cursor-not-allowed bg-gray-200 text-gray-400'"
class="rounded-btn px-6 py-3 text-sm font-semibold transition"
>
<span x-text="selectedSize ? 'Add to cart' : 'Select a size'"></span>
</button>
</div>
The computed sku getter derives its value reactively from the two selected state variables. Alpine tracks this automatically — no watchers or computed declarations needed. The "Add to cart" button is both visually and functionally disabled until a size is chosen, which reduces invalid form submissions without any extra event-handling code.
Part 3: Tabbed specs panel
Tab UIs are a common Alpine pattern. The key is keeping the state simple: one string tracking the active tab ID.
<div
x-data="{ tab: 'overview' }"
class="rounded-card border bg-surface"
>
<!-- Tab bar -->
<div class="flex border-b" role="tablist">
<template x-for="t in ['overview', 'specs', 'shipping']" :key="t">
<button
@click="tab = t"
:aria-selected="tab === t"
role="tab"
:class="tab === t
? 'border-b-2 border-brand text-brand font-semibold'
: 'text-muted hover:text-gray-700'"
class="-mb-px px-5 py-3 text-sm capitalize transition"
x-text="t"
></button>
</template>
</div>
<!-- Panels -->
<div class="p-6">
<div x-show="tab === 'overview'" x-transition.opacity.duration.200ms>
<h3 class="mb-2 font-semibold">About this product</h3>
<p class="text-sm text-muted leading-relaxed">
Built from mid-weight organic cotton with reinforced seams. Designed for
all-day comfort whether you're on a job site or heading out after work.
</p>
</div>
<div x-show="tab === 'specs'" x-transition.opacity.duration.200ms>
<dl class="grid grid-cols-2 gap-3 text-sm">
<dt class="text-muted">Material</dt>
<dd class="font-medium">100% organic cotton</dd>
<dt class="text-muted">Weight</dt>
<dd class="font-medium">280 gsm</dd>
<dt class="text-muted">Care</dt>
<dd class="font-medium">Machine wash cold</dd>
<dt class="text-muted">Origin</dt>
<dd class="font-medium">Portugal</dd>
</dl>
</div>
<div x-show="tab === 'shipping'" x-transition.opacity.duration.200ms>
<ul class="space-y-2 text-sm text-muted">
<li>Free standard shipping on orders over $75</li>
<li>Express 2-day shipping available at checkout</li>
<li>Free returns within 30 days</li>
</ul>
</div>
</div>
</div>
The shorthand x-transition.opacity.duration.200ms applies a 200ms fade to the panel without any scale effect — appropriate here because the panels share the same physical space and a scale transition would feel awkward. The role="tab" and aria-selected bindings keep the component accessible without extra effort.
Putting it together in Astro
In an Astro project, you can drop these components into .astro files exactly as written. Alpine works in any static HTML — no Astro-specific adapter needed. Add the CDN script to your base layout, or install Alpine as a package and initialize it in a <script> tag in your layout:
// src/layouts/BaseLayout.astro (script section)
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
Your Tailwind v4 tokens defined in global.css will automatically apply across every Alpine component since they're just CSS custom properties.
A note on component boundaries
One thing that catches developers new to Alpine: each x-data block is its own scope. The gallery, variant selector, and tabs above are all siblings — they do not share state. If you need the variant selection to update the gallery images (a common product page requirement), wrap all three in a single parent x-data component and pass state down. For complex cases, Alpine's Alpine.store() gives you a global reactive store without the overhead of a full state management library.
Accessibility quick-check
Before shipping, run through these three items:
- Every image has a meaningful
altattribute oralt=""for purely decorative images. - Tab buttons use
role="tab"andaria-selectedas shown above. Wrap them in arole="tablist"element. - Disabled buttons (like "Add to cart" before a size is selected) should use
:disabled="true"on the native button element — not just a visual style — so screen readers announce the state correctly.
Wrapping up
Alpine.js and Tailwind CSS v4 are a genuinely good fit for product page UI. Alpine keeps your interactivity in the HTML, close to the markup it controls, which makes it easy to read a template and understand its behavior without tracing JavaScript files. Tailwind v4's @theme directive means your brand tokens — the specific blue, the corner radius, the neutral surface color — are defined once and available as utility classes everywhere. Together, they give you a fast, maintainable product page without the overhead of a heavier component framework.