Pricing Table
The plan grid every product page needs. Price formats a single amount; this is the block people choose from.
Basic Usage
-
Free
For trying things out
€0.00 /mo- 1 project
- Community support
-
Pro Most popular
For growing teams
€29.00 /mo- Unlimited projects
- Priority support
- Audit log
-
Enterprise
For the whole company
Let us talk- Everything in Pro
- SSO and SCIM
- A named contact
Amounts are formatted, not typed
Pass the raw amount and a currency. The tier renders it through
Price, so every plan is formatted for the visitor's locale
and no one hard-codes "€29" into copy.
{{-- 1. Raw number in — locale-formatted currency out --}}
<x-wirekit::pricing-tier name="Pro" :price="$plan->price_cents / 100" currency="EUR" period="mo" />
A tier with no number — "Let us talk" — uses priceLabel instead. It never
invents an amount.
The featured plan
featured marks the plan you want chosen. It gets a badge and a ring — not
a tint. A highlight that exists only as a color is invisible to a screen reader
and to anyone who cannot separate your accent from your border.
It is deliberately not scaled up: enlarging one card reflows the row and makes the neighbors look broken on a phone.
Billing interval toggle
Give the table an intervals map and it renders the toggle itself. Each tier
then carries interval-keyed prices, and switching costs no server round trip:
-
Starter
For a single project.
€19.00 /mo -
Team Most popular
For a growing team.
€49.00 /mo
Every amount is formatted on the server by the price component — the toggle only switches which one is visible, so your currency, locale and minor-unit rules are never reimplemented in JavaScript. Before JavaScript runs, the first interval is the one on screen.
<x-wirekit::pricing-table :intervals="['monthly' => 'Monthly', 'annual' => 'Annual −20%']">
<x-wirekit::pricing-tier
name="Team"
:prices="['monthly' => 4900, 'annual' => 47000]"
:periods="['monthly' => 'mo', 'annual' => 'yr']"
:minor-units="true"
currency="EUR"
/>
</x-wirekit::pricing-table>
A tier with a single flat price still works inside an interval table — a free
plan does not need two amounts.
Driving the interval from your own state
When the choice has to reach the server — because it changes what you query, or you persist it — bind the table itself:
{{-- 1. The table owns the toggle and reports the choice like a form control. --}}
<x-wirekit::pricing-table wire:model.live="billing" interval="monthly">
@foreach($plans as $plan)
<x-wirekit::pricing-tier
:name="$plan->name"
:price="$billing === 'yearly' ? $plan->yearly : $plan->monthly"
currency="EUR"
:period="$billing === 'yearly' ? 'year' : 'mo'"
:featured="$plan->is_popular"
/>
@endforeach
</x-wirekit::pricing-table>
It works inside a plain <form> too — the value travels in a hidden input, so it
submits under the name you give it without any JavaScript on your side:
{{-- 2. No Livewire anywhere. The choice arrives in $request->input('billing'). --}}
<form method="POST" action="/checkout">
@csrf
<x-wirekit::pricing-table name="billing" interval="yearly">…</x-wirekit::pricing-table>
<x-wirekit::button type="submit">Continue</x-wirekit::button>
</form>
Before the table had interval, the way to do this was to keep the amounts on
one property and switch them yourself with a separate
Segmented Control. That still works and is worth
knowing if your toggle drives more than the prices:
{{-- 1. One property decides which set of amounts renders --}}
<x-wirekit::segmented-control wire:model.live="billing" :options="['monthly' => 'Monthly', 'yearly' => 'Yearly (-20%)']" />
<x-wirekit::pricing-table>
@foreach($plans as $plan)
{{-- 2. The tier just renders the amount it is handed --}}
<x-wirekit::pricing-tier
:name="$plan->name"
:price="$billing === 'yearly' ? $plan->yearly : $plan->monthly"
currency="EUR"
:period="$billing === 'yearly' ? 'year' : 'mo'"
:featured="$plan->is_popular"
/>
@endforeach
</x-wirekit::pricing-table>
Fitting the grid to your plans
columns sets how many plans sit side by side at the widest breakpoint —
otherwise a two-plan table renders the three-column ladder and leaves a gap where
the third plan would have been. One plan per row on a phone in every case.
<x-wirekit::pricing-table :columns="2">…</x-wirekit::pricing-table>
SEO
Emit Offer structured data alongside the table with
Structured Data — but only for the plans the page
actually shows.
Props
pricing-table
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string | 'Pricing plans' |
Accessible name for the list of plans |
intervals |
array|null | null |
Billing intervals as key => label. Given, the table renders its own toggle and the tiers switch their prices. |
intervalLabel |
string | 'Billing interval' |
Accessible name for the interval toggle |
columns |
int | 3 |
Plans side by side at the widest breakpoint (1, 2, 3, 4). One per row on a phone regardless. |
name |
string |
'interval' |
Name of the hidden input the chosen interval is written to, so a surrounding <form> carries it on submit. Only rendered when intervals is given. |
interval |
string|null |
null |
The interval as the server sees it. The first key of intervals is only the opening position; this one keeps arriving, so a checkout decided on the server can read the choice and correct it. wire:model on the component binds this value. |
scope |
string|null | null |
Scoped personalization name |
pricing-tier
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string | '' |
Plan name |
price |
mixed | null |
Raw amount — formatted by Price |
prices |
array|null | null |
Interval-keyed amounts, e.g. ['monthly' => 4900, 'annual' => 47000]. Each is formatted server-side; the table's toggle switches which is visible. |
periods |
array|null | null |
Per-interval period labels, e.g. ['annual' => 'yr']. Falls back to period. |
currency |
string|null | null |
Currency code; falls back to your config |
period |
string|null | null |
mo, year, seat/mo … |
description |
string|null | null |
Copy under the plan name |
featured |
bool | false |
Badge + ring highlight |
featuredLabel |
string | 'Most popular' |
Text for the featured badge |
priceLabel |
string|null | null |
Shown instead of an amount ("Let us talk") |
minorUnits |
bool | false |
Treat price as minor units (e.g. 4900 → 49), forwarded to the inner price. |
locale |
string|null | null |
Locale for amount formatting, forwarded to the inner price. |
priceSize |
string | 'lg' |
Size of the amount — 'xs'…'xl', forwarded to the inner price (was hardcoded lg). |
scope |
string|null | null |
Scoped personalization name |
Slots
| Slot | Description |
|---|---|
features |
The feature list |
action |
The plan's CTA |
Accessibility
- The table is a real
<ul role="list">with a label, and each plan is an<li>— so a screen reader announces "list, 3 items" and the reader knows how many plans they are comparing before they start. - The featured highlight is never color-only (WCAG 1.4.1): the badge states "Most popular" in text; the ring is the visual echo, not the message.
- Every CTA is pinned to the same baseline (
mt-auto), so the row stays scannable however uneven the feature lists are. - Give each CTA a name that says which plan it buys ("Choose Pro"), not just "Choose" — a screen-reader user tabbing the row hears them out of context.
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Move through the plan CTAs in order |
Pitfalls
- Do not hard-code the currency into copy. Pass
price+currencyand let Price format it. - Do not invent a number for the enterprise tier. Use
priceLabel. - Do not scale the featured plan. It reflows the row on a phone; the ring and badge are enough.
- Do not label every CTA "Choose". Say which plan.
Design Tokens
| Element | Token |
|---|---|
| Plan surface | --color-wk-bg-elevated |
| Plan border | --color-wk-border, --border-wk-width |
| Plan radius | --radius-wk-lg |
| Plan padding | --padding-wk-x-lg |
| Featured ring | --color-wk-accent |
| Grid gap | --gap-wk-md |
| Name / description | --text-wk-lg, --text-wk-sm, --color-wk-text-muted |