How to build a responsive pricing table with Tailwind CSS and Alpine.js for pricing toggle.
Recreate a three-tier enterprise pricing table with a billing toggle, grouped feature lists, and a desktop-only layout using Tailwind CSS plus a sprinkle of Alpine.js.
Published on November 26, 2025 by Michael AndreuzzaPricing conversations usually happen on large screens, so this layout hides the table on smaller breakpoints and shows a clear “desktop only” prompt. Alpine.js drives the monthly/annual toggle, while Tailwind handles all the spacing, typography, and highlights.
Table anatomy
- Toggle component animates between monthly/annual prices with a sliding background.
- Three tiers (Core, Momentum, Growth) stay left-aligned so long plan names don’t stretch the cells.
- Table body is grouped into Usage and Features sections via
scope="colgroup"rows, making the copy scannable for screen readers. - Highlight states rely on
bg-blue-50/border-blue-100andtext-whiteto emphasize the middle tier.
1. Initialize Alpine state and wrapper
Alpine keeps the currently selected billing duration and lets you reuse that value anywhere in the markup.
<div x-data="{ duration: 'monthly' }" class="space-y-6">
<!-- Prompt + table live here -->
</div>
- Default to
monthlyso the toggle renders with the left button active. - Use
space-y-6(orgap-y-*) to add breathing room above the table.
2. Desktop-only prompt and heading row
A short message warns mobile visitors that the data is best read on desktop. The header grid aligns the marketing copy on the left and the billing toggle on the right.
<p class="text-center text-zinc-500 lg:hidden">Pricing table for desktop use</p>
<div class="hidden lg:block">
<div
class="grid grid-cols-1 gap-12 md:grid-cols-2 md:items-end text-center md:text-left"
>
<h1
class="text-2xl md:text-3xl lg:text-4xl font-medium tracking-tight text-zinc-900 text-balance"
>
Equip your business with world class software
</h1>
<!-- toggle goes here -->
</div>
</div>
lg:hiddenkeeps the notice on mobile while the actual table stays hidden.text-balance(Tailwind plugin) prevents awkward wrapping in the main heading.
3. Build the toggle with Alpine bindings
A sliding div indicates the active option. x-text pulls the right price from data-* attributes, so you don’t need conditional logic.
<div
aria-labelledby="pricing-toggle"
class="relative z-0 inline-flex justify-center w-full p-1 overflow-hidden bg-white shadow ring-1 ring-zinc-200 ring-offset-2 gap-4 rounded-md max-w-52 lg:ml-auto"
>
<div
class="absolute inset-0 bg-zinc-50 rounded-md transition-transform duration-200 ease-in-out"
:class="duration === 'monthly' ? 'translate-x-0 w-1/2' : 'translate-x-full w-1/2'"
></div>
<button
id="pricing-toggle"
class="relative z-10 flex items-center justify-center w-full h-6 px-2 text-xs font-medium transition-colors"
:class="duration === 'monthly' ? 'text-zinc-600' : 'hover:text-zinc-900'"
type="button"
@click="duration = 'monthly'"
:aria-pressed="duration === 'monthly'"
>
Monthly
</button>
<button
class="relative z-10 flex items-center justify-center w-full h-6 px-2 text-xs font-medium transition-colors"
:class="duration === 'annual' ? 'text-zinc-600' : 'hover:text-zinc-900'"
type="button"
@click="duration = 'annual'"
:aria-pressed="duration === 'annual'"
>
Annual
</button>
</div>
- The animated background sits absolutely positioned underneath the buttons.
- Alpine toggles
durationwith a single click handler and reuses the value across the table.
4. Price columns with data attributes
Each plan uses a span with data-monthly/data-annual values. Alpine swaps the number via x-text, and the smaller copy explains whether the billing unit is monthly or annual.
<span
data-monthly="$49"
data-annual="$39"
x-text="$el.dataset[duration]"
></span>
- Keep the
spaninside aflexcontainer so the price aligns next to the billing cadence. - Use
font-medium+text-4xlto balance weight with the supporting copy below.
5. Usage + features sections
Two major groups break up the dense data. Each th row uses an icon and label on the left, while the plan cells stay consistent with px-2 text-xs font-medium classes. Highlighted cells use bg-blue-50 and border-blue-100 to reinforce the recommended tier.
<tr class="overflow-hidden">
<th
scope="colgroup"
colspan="4"
class="py-4 text-base font-medium text-left leading-6 text-zinc-900 border-y border-zinc-200"
>
Usage
</th>
</tr>
scope="colgroup"tells screen readers the row is descriptive, not data.- Add
border-tto every following row so the sections stay separated.
6. Copy-and-paste markup
Drop the entire snippet below anywhere inside your Astro/HTML template. Alpine only needs to be initialized once per page.
<div x-data="{ duration: 'monthly' }" class="space-y-6">
<p class="text-center text-zinc-500 lg:hidden">
Pricing table for desktop use
</p>
<!---- Use only from xl: -->
<div class="hidden lg:block">
<div
class="text-center md:text-left grid grid-cols-1 gap-12 md:grid-cols-2 md:items-end"
>
<h1
class="text-2xl md:text-3xl lg:text-4xl font-medium tracking-tight text-zinc-900 text-balance"
>
Equip your business with world class software
</h1>
<div
aria-labelledby="pricing-toggle"
class="relative z-0 inline-flex justify-center w-full p-1 overflow-hidden bg-white shadow ring-1 ring-zinc-200 ring-offset-2 gap-4 rounded-md max-w-52 lg:ml-auto"
>
<div
class="absolute inset-0 bg-zinc-50 rounded-md transition-transform duration-200 ease-in-out"
:class="duration === 'monthly' ? 'w-1/2 translate-x-0' : 'w-1/2 translate-x-full'"
></div>
<button
class="relative z-10 flex items-center justify-center w-full h-6 px-2 text-xs font-medium focus:outline-none transition-colors duration-300"
:class="duration === 'monthly' ? 'text-zinc-600 ' : 'focus:text-zinc-900 hover:text-zinc-900 '"
@click="duration = 'monthly'"
type="button"
:aria-pressed="duration === 'monthly'"
>
Monthly
</button>
<button
class="relative z-10 flex items-center justify-center w-full h-6 px-2 text-xs font-medium focus:outline-none transition-colors duration-300"
:class="duration === 'annual' ? 'text-zinc-600 ' : 'focus:text-zinc-900 hover:text-zinc-900 '"
@click="duration = 'annual'"
type="button"
:aria-pressed="duration === 'annual'"
>
Annual
</button>
</div>
</div>
<table class="w-full mx-auto mt-8 text-center bg-transparent">
<thead>
<tr class="align-top">
<th class="w-1/4"></th>
<th class="relative w-1/4 p-1 pr-0 font-normal text-left">
<div
aria-hidden="true"
class="absolute inset-y-0 right-0 hidden w-full"
></div>
<div class="relative pb-4 pr-2">
<div class="flex items-start justify-between w-full">
<div>
<h3 class="text-base font-semibold text-zinc-900">Core</h3>
<p class="text-sm mt-1 text-zinc-500">For individuals</p>
</div>
</div>
<div class="mt-8">
<p
class="text-4xl font-medium sm:text-xl md:text-4xl text-zinc-900"
></p>
<div class="flex items-center gap-x-4">
<p
class="text-4xl font-medium sm:text-xl md:text-4xl text-zinc-900"
>
<span
data-monthly="$29"
data-annual="$19"
x-text="$el.dataset[duration]"
></span>
</p>
<div class="text-xs font-medium capitalize text-zinc-900">
<span x-show="duration === 'monthly'">
<span x-show="duration === 'monthly'"> month</span></span
>
<span x-show="duration === 'annual'" style="display: none">
annually</span
>
<p class="text-sm text-zinc-500">USD + Local taxes</p>
</div>
</div>
<div class="mt-8">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-700 bg-white outline outline-zinc-200 hover:shadow-sm hover:bg-zinc-50 focus-visible:outline-zinc-900 h-9 px-4 text-sm w-full"
>
Get Started
</button>
</div>
</div>
</div>
</th>
<th class="relative w-1/4 p-1 px-0 font-normal text-left">
<div
aria-hidden="true"
class="absolute inset-y-0 right-0 hidden w-full sm:block"
></div>
<div class="relative px-2 pb-4">
<div>
<h3 class="text-base font-medium text-zinc-900">Momentum</h3>
<p class="text-sm mt-1 text-zinc-500">For small businesses</p>
</div>
<div class="mt-8">
<div class="flex items-center gap-x-4">
<p
class="text-4xl font-medium text-zinc-900 sm:text-xl md:text-4xl"
>
<span
data-monthly="$49"
data-annual="$39"
x-text="$el.dataset[duration]"
></span>
</p>
<div class="text-xs font-medium capitalize text-zinc-900">
<span x-show="duration === 'monthly'">
<span x-show="duration === 'monthly'"> month</span></span
>
<span x-show="duration === 'annual'" style="display: none">
annually</span
>
<p class="text-sm text-zinc-500">USD + Local taxes</p>
</div>
</div>
<div class="mt-8">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus-visible:outline-zinc-950 h-9 px-4 text-sm w-full"
>
Get Started
</button>
</div>
</div>
</div>
</th>
<th class="relative w-1/4 p-1 pl-0 font-normal text-left">
<div
aria-hidden="true"
class="absolute inset-y-0 right-0 hidden w-full rounded-tr-xl sm:block"
></div>
<div class="relative pb-4 pl-2">
<div class="flex items-start justify-between w-full">
<div>
<h3 class="text-base font-semibold text-zinc-900">Growth</h3>
<p class="text-sm mt-1 text-zinc-500">For big companies</p>
</div>
</div>
<div class="mt-8">
<p
class="text-4xl font-medium sm:text-xl md:text-4xl text-zinc-900"
></p>
<div class="flex items-center gap-x-4">
<p
class="text-4xl font-medium sm:text-xl md:text-4xl text-zinc-900"
>
<span
data-monthly="$99"
data-annual="$129"
x-text="$el.dataset[duration]"
></span>
</p>
<div class="text-xs font-medium capitalize text-zinc-900">
<span x-show="duration === 'monthly'">
<span x-show="duration === 'monthly'"> month</span></span
>
<span x-show="duration === 'annual'" style="display: none">
annually</span
>
<p class="text-sm text-zinc-500">USD + Local taxes</p>
</div>
</div>
<div class="mt-8">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-700 bg-white outline outline-zinc-200 hover:shadow-sm hover:bg-zinc-50 focus-visible:outline-zinc-900 h-9 px-4 text-sm w-full"
>
Get Started
</button>
</div>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<tr class="overflow-hidden">
<th
scope="colgroup"
colspan="4"
class="py-4 text-base font-medium text-left leading-6 text-zinc-900 border-y border-zinc-200"
>
Usage
</th>
</tr>
<tr>
<th class="py-3 text-sm font-medium text-left text-zinc-500">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path>
</svg>
Analytics
</div>
</th>
<td class="px-2 text-xs font-medium text-zinc-900">3</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
10
</td>
<td class="px-2 text-xs font-medium text-zinc-900">Unlimited</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Accounts
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
5
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
10
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Integrations
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
2
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
YES
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Programs
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
NO
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
1
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Deployments
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
NO
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
YES
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Advanced Checkpoints
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
NO
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
YES
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Reporting tools
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
NO
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
YES
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Support
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Discord community
</td>
<td
class="px-2 text-xs font-medium text-white border-t border-blue-100 bg-blue-50 text-zinc-900"
>
Live chat
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Unlimited
</td>
</tr>
<tr class="overflow-hidden">
<th
scope="colgroup"
colspan="4"
class="py-4 text-base font-medium text-left leading-6 text-zinc-900 border-y border-zinc-200"
>
Features
</th>
</tr>
<tr>
<th class="py-3 text-sm font-medium text-left text-zinc-500">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path>
</svg>
Service agreement
</div>
</th>
<td class="px-2 text-xs font-medium text-zinc-900">No</td>
<td
class="px-2 text-xs font-medium text-white border-t border-blue-100 bg-blue-50 text-zinc-900"
>
No
</td>
<td class="px-2 text-xs font-medium text-zinc-900">YES</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Dedicted Manager
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
No
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
Yes
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Yes
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Onboarding sessions
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
No
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
No
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200"
>
Yes
</td>
</tr>
<tr>
<th
class="py-3 text-sm font-medium text-left border-t text-zinc-900 border-zinc-200"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-circle-check size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path></svg
>Dedicated support channel
</div>
</th>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200 rounded-bl-xl"
>
No
</td>
<td
class="px-2 text-xs font-medium border-t border-blue-100 bg-blue-50 text-zinc-900"
>
No
</td>
<td
class="px-2 text-xs font-medium border-t text-zinc-900 border-zinc-200 rounded-br-xl"
>
Yes
</td>
</tr>
</tbody>
</table>
</div>
</div>
Finishing touches
- Keep the pricing copy editable: change the numbers on the
data-*attributes and Alpine will update everywhere. - If you need a mobile-friendly version, duplicate the plan data into stacked cards inside a
lg:hiddenblock and reuse the same Alpine state. - Turn the
Get Startedbuttons into<a>tags when you have specific plan URLs; ensure each one hasaria-labeltext if the button copy stays generic.
/Michael Andreuzza