Summit Themes
Blog

How to build a responsive pricing table with Tailwind CSS and Alpine.js for pricing toggle.

A pricing table is one of the most conversion-sensitive pieces of UI on any product page. It also has a notorious habit of getting messy fast — especially when you add a monthly/annual toggle. Reach for a heavy component library and you end up fighting theming. Write it all in vanilla JavaScript and you end up maintaining state by hand. Tailwind CSS v4 and Alpine.js are a tighter pairing: Tailwind handles every visual detail through utility classes, and Alpine adds just enough reactive state to make the toggle feel instant.

This guide walks through building a real, responsive three-tier pricing table with a billing-period toggle from scratch. The finished component is self-contained HTML — no bundler required for the Alpine part, and Tailwind v4 compiles the styles. Every snippet below is copy-pasteable and tested against current APIs.

What you are building

Three pricing cards (Starter, Pro, Agency) laid out in a responsive grid. A toggle at the top switches between monthly and annual billing; annual pricing shows a discount badge. The active plan card gets a visual highlight. On mobile the cards stack vertically; on lg screens they sit side-by-side. No page reload, no JavaScript framework.

Setting up Tailwind CSS v4

Tailwind v4 drops the tailwind.config.js file entirely. Configuration lives in your CSS using the @theme directive. Install the package and the Vite plugin (or the PostCSS plugin — same idea):

npm install tailwindcss @tailwindcss/vite

Then in your main CSS file, replace the old @tailwind directives with a single import and define any custom tokens inside @theme:

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.55 0.22 264);
  --color-brand-600: oklch(0.48 0.22 264);
  --color-brand-50:  oklch(0.97 0.03 264);
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}

Any token defined in @theme automatically generates a corresponding utility class. --color-brand-500 becomes bg-brand-500, text-brand-500, border-brand-500, and so on. You do not need a separate :root block — Tailwind compiles @theme variables into :root for you.

Adding Alpine.js

Alpine.js is a CDN-first library. Drop the script tag before your closing </body>:

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

If you are using a bundler, install it instead:

npm install alpinejs
import Alpine from "alpinejs";
window.Alpine = Alpine;
Alpine.start();

Either way, the directives are identical. Alpine's reactive model is declarative: you declare state with x-data, bind it to elements with x-show, x-text, and :class, and toggle it with @click. No component files, no virtual DOM.

The toggle component

The toggle itself is a small wrapper div that owns the annual boolean. Everything inside it reacts to that single variable:

<div
  x-data="{ annual: false }"
  x-cloak
  class="mx-auto max-w-5xl px-4 py-16"
>
  <!-- Toggle switch -->
  <div class="mb-12 flex items-center justify-center gap-4">
    <span
      :class="annual ? 'text-gray-400' : 'text-gray-900 font-semibold'"
    >Monthly</span>

    <button
      @click="annual = !annual"
      :class="annual ? 'bg-brand-500' : 'bg-gray-300'"
      class="relative inline-flex h-7 w-14 items-center rounded-full transition-colors duration-200"
      role="switch"
      :aria-checked="annual.toString()"
      aria-label="Toggle billing period"
    >
      <span
        :class="annual ? 'translate-x-8' : 'translate-x-1'"
        class="inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200"
      ></span>
    </button>

    <span
      :class="annual ? 'text-gray-900 font-semibold' : 'text-gray-400'"
    >
      Annual
      <span class="ml-1.5 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-600">
        Save 20%
      </span>
    </span>
  </div>

  <!-- Pricing cards go here -->
</div>

Two things worth noting here. The x-cloak attribute on the outer div prevents Alpine-controlled content from flickering while the script initialises — add a matching CSS rule to your stylesheet:

[x-cloak] { display: none !important; }

The :class binding merges computed classes with the static class attribute. When annual is true, the pill background switches from bg-gray-300 to bg-brand-500, and the knob translates right. The role="switch" and :aria-checked bindings make the control accessible to screen readers.

The pricing card grid

Place this inside the same x-data wrapper so every card can read the annual variable:

<div class="grid gap-8 lg:grid-cols-3">

  <!-- Starter -->
  <div class="rounded-2xl border border-gray-200 bg-white p-8">
    <h2 class="text-lg font-semibold text-gray-900">Starter</h2>
    <p class="mt-1 text-sm text-gray-500">Perfect for freelancers and side projects.</p>

    <div class="mt-6 flex items-baseline gap-1">
      <span class="text-4xl font-bold text-gray-900">$</span>
      <span
        class="text-5xl font-extrabold text-gray-900"
        x-text="annual ? '19' : '25'"
      ></span>
      <span class="text-gray-500">/mo</span>
    </div>
    <p x-show="annual" class="mt-1 text-sm text-gray-400">Billed $228/year</p>

    <ul class="mt-8 space-y-3 text-sm text-gray-600">
      <li>5 projects</li>
      <li>10 GB storage</li>
      <li>Email support</li>
    </ul>

    <a
      href="#"
      class="mt-8 block rounded-xl border border-gray-300 px-6 py-3 text-center text-sm font-semibold text-gray-700 transition hover:border-brand-500 hover:text-brand-600"
    >Get started</a>
  </div>

  <!-- Pro (highlighted) -->
  <div class="rounded-2xl bg-brand-500 p-8 text-white shadow-xl">
    <h2 class="text-lg font-semibold">Pro</h2>
    <p class="mt-1 text-sm text-brand-50 opacity-80">For growing teams that need more power.</p>

    <div class="mt-6 flex items-baseline gap-1">
      <span class="text-4xl font-bold">$</span>
      <span
        class="text-5xl font-extrabold"
        x-text="annual ? '59' : '75'"
      ></span>
      <span class="opacity-75">/mo</span>
    </div>
    <p x-show="annual" class="mt-1 text-sm opacity-60">Billed $708/year</p>

    <ul class="mt-8 space-y-3 text-sm opacity-90">
      <li>Unlimited projects</li>
      <li>100 GB storage</li>
      <li>Priority support</li>
      <li>Team collaboration</li>
    </ul>

    <a
      href="#"
      class="mt-8 block rounded-xl bg-white px-6 py-3 text-center text-sm font-semibold text-brand-600 transition hover:bg-brand-50"
    >Get started</a>
  </div>

  <!-- Agency -->
  <div class="rounded-2xl border border-gray-200 bg-white p-8">
    <h2 class="text-lg font-semibold text-gray-900">Agency</h2>
    <p class="mt-1 text-sm text-gray-500">White-label ready for client work at scale.</p>

    <div class="mt-6 flex items-baseline gap-1">
      <span class="text-4xl font-bold text-gray-900">$</span>
      <span
        class="text-5xl font-extrabold text-gray-900"
        x-text="annual ? '119' : '149'"
      ></span>
      <span class="text-gray-500">/mo</span>
    </div>
    <p x-show="annual" class="mt-1 text-sm text-gray-400">Billed $1,428/year</p>

    <ul class="mt-8 space-y-3 text-sm text-gray-600">
      <li>Unlimited projects</li>
      <li>1 TB storage</li>
      <li>Dedicated account manager</li>
      <li>Custom contracts</li>
    </ul>

    <a
      href="#"
      class="mt-8 block rounded-xl border border-gray-300 px-6 py-3 text-center text-sm font-semibold text-gray-700 transition hover:border-brand-500 hover:text-brand-600"
    >Get started</a>
  </div>

</div>

How the price swap works

The key directive is x-text. When Alpine evaluates x-text="annual ? '59' : '75'", it sets the element's textContent to the result. When annual flips, every x-text binding in the component tree re-evaluates and the DOM updates synchronously — no setTimeout, no manual querySelector.

The annual billing note under each price uses x-show, which toggles display: none on the element. Alpine does not destroy the element; it just hides it. That means there is no layout shift from elements mounting or unmounting — the paragraph is already in the DOM at the right size, just invisible when you are on monthly view.

Adding smooth transitions

Alpine ships built-in transition support via x-transition. Adding it to the annual billing note gives a subtle fade-in:

<p
  x-show="annual"
  x-transition:enter="transition-opacity duration-200"
  x-transition:enter-start="opacity-0"
  x-transition:enter-end="opacity-100"
  x-transition:leave="transition-opacity duration-150"
  x-transition:leave-start="opacity-100"
  x-transition:leave-end="opacity-0"
  class="mt-1 text-sm text-gray-400"
>Billed $228/year</p>

If you want a single shorthand, x-transition with no modifiers applies Alpine's default opacity + scale transition, which is fine for most use cases. The explicit modifiers above give you more control over timing.

Using the component in Astro

If you are building with Astro, drop the entire block into any .astro file. Astro ships zero JavaScript to the client by default, but Alpine runs through its own CDN script tag — it does not need to go through Astro's component model. The pattern is:

---
// src/pages/pricing.astro
// No imports needed for Alpine — it loads via CDN script tag
---

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Pricing</title>
  <link rel="stylesheet" href="/styles/global.css" />
  <style>[x-cloak] { display: none !important; }</style>
</head>
<body>
  <!-- pricing table markup goes here -->

  <script
    defer
    src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"
  ></script>
</body>
</html>

One small Astro-specific note: Astro's template syntax uses curly braces for expressions, which can conflict with Alpine's x-data JSON if you inline an object directly. Prefer defining the data as a named Alpine component to sidestep the issue:

<script>
  document.addEventListener("alpine:init", () => {
    Alpine.data("pricing", () => ({
      annual: false,
    }));
  });
</script>

<div x-data="pricing">
  <!-- toggle and cards -->
</div>

Accessibility checklist

A few things that are easy to miss on interactive pricing tables:

  • The toggle button needs role="switch" and a dynamic aria-checked so screen readers announce the current billing mode.
  • Price changes driven by x-text are announced by screen readers when they occur inside a live region. Wrap the price element in aria-live="polite" if the price change is the primary action.
  • Make sure the "Get started" links have descriptive text or aria-label attributes — "Get started" repeated three times is ambiguous for a screen reader user scanning links.
  • Color contrast on the highlighted Pro card: white text on brand-500 should pass WCAG AA (4.5:1 for normal text). Test this in your browser's accessibility panel if you choose custom brand colors.

Responsive behaviour

The grid uses grid gap-8 lg:grid-cols-3. On screens below the lg breakpoint (1024 px by default in Tailwind v4), the cards stack into a single column. If you want a two-column layout on medium screens, add md:grid-cols-2. The Pro card sits in the middle column on desktop, which naturally emphasises it as the recommended tier — on mobile it falls between Starter and Agency in the stacked list, which preserves the same visual hierarchy.

Keeping prices in one place

As your product grows, scattering price literals across the template becomes a maintenance problem. Consider defining them in a data object:

<div x-data="{
  annual: false,
  plans: [
    { name: 'Starter', monthly: 25, annual: 19, yearlyTotal: 228 },
    { name: 'Pro',     monthly: 75, annual: 59, yearlyTotal: 708 },
    { name: 'Agency',  monthly: 149, annual: 119, yearlyTotal: 1428 }
  ]
}">

Then use x-text and template to iterate. This keeps the numbers in one place and makes it easier to run A/B tests or pull pricing from a CMS without touching the markup.

In Astro, you can also keep pricing data in a content collection and pass the values as props to a component, generating the x-data JSON at build time and letting Alpine handle only the client-side toggle logic.

Putting it all together

The full component is around 80 lines of HTML and two lines of CSS. There is no JavaScript file to maintain, no state management library, no component lifecycle to reason about. Tailwind handles all the visual states — active toggle color, card highlight, hover effects — through utility classes that Alpine toggles by binding to the annual variable. When you need to change pricing, you change a number. When you need to add a plan, you add a card to the grid.

That combination — Tailwind for style, Alpine for state — is worth reaching for any time you need interactive UI without the overhead of a full framework. Pricing tables are a good demonstration of why: the interaction is simple, but the visual detail has to be right, and the two tools each stay in their lane.