---
title: Pricing Table
description: A row of pricing plans — formatted amounts, feature lists, and a featured highlight
visibility: guest
draft: false
---

# Pricing Table

The plan grid every product page needs. [Price](/components/price) formats a
single amount; this is the block people choose from.

## Basic Usage

:::preview{title="Three plans"}
<x-wirekit::pricing-table label="Plans">
    <x-wirekit::pricing-tier name="Free" :price="0" currency="EUR" period="mo" description="For trying things out">
        <x-slot:features>
            <x-wirekit::list spacing="sm">
                <li>1 project</li>
                <li>Community support</li>
            </x-wirekit::list>
        </x-slot:features>
        <x-slot:action>
            <x-wirekit::button intent="neutral" surface="outline" class="w-full">Start free</x-wirekit::button>
        </x-slot:action>
    </x-wirekit::pricing-tier>

    <x-wirekit::pricing-tier name="Pro" :price="29" currency="EUR" period="mo" description="For growing teams" featured>
        <x-slot:features>
            <x-wirekit::list spacing="sm">
                <li>Unlimited projects</li>
                <li>Priority support</li>
                <li>Audit log</li>
            </x-wirekit::list>
        </x-slot:features>
        <x-slot:action>
            <x-wirekit::button intent="primary" class="w-full">Choose Pro</x-wirekit::button>
        </x-slot:action>
    </x-wirekit::pricing-tier>

    <x-wirekit::pricing-tier name="Enterprise" price-label="Let us talk" description="For the whole company">
        <x-slot:features>
            <x-wirekit::list spacing="sm">
                <li>Everything in Pro</li>
                <li>SSO and SCIM</li>
                <li>A named contact</li>
            </x-wirekit::list>
        </x-slot:features>
        <x-slot:action>
            <x-wirekit::button intent="neutral" surface="outline" class="w-full">Contact sales</x-wirekit::button>
        </x-slot:action>
    </x-wirekit::pricing-tier>
</x-wirekit::pricing-table>
:::

## Amounts are formatted, not typed

Pass the **raw** amount and a currency. The tier renders it through
[Price](/components/price), so every plan is formatted for the visitor's locale
and no one hard-codes `"€29"` into copy.

```blade
{{-- 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:

:::preview{title="Monthly / annual toggle"}
<x-wirekit::pricing-table :intervals="['monthly' => 'Monthly', 'annual' => 'Annual −20%']" :columns="2" label="Plans">
    <x-wirekit::pricing-tier
        name="Starter"
        :prices="['monthly' => 1900, 'annual' => 18200]"
        :periods="['monthly' => 'mo', 'annual' => 'yr']"
        :minor-units="true"
        currency="EUR"
        description="For a single project."
    />
    <x-wirekit::pricing-tier
        name="Team"
        :prices="['monthly' => 4900, 'annual' => 47000]"
        :periods="['monthly' => 'mo', 'annual' => 'yr']"
        :minor-units="true"
        currency="EUR"
        description="For a growing team."
        :featured="true"
    />
</x-wirekit::pricing-table>
:::

Every amount is **formatted on the server** by the [price](/components/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.

```blade
<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:

```blade
{{-- 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:

```blade
{{-- 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](/components/segmented-control). That still works and is worth
knowing if your toggle drives more than the prices:

```blade
{{-- 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.

```blade
<x-wirekit::pricing-table :columns="2">…</x-wirekit::pricing-table>
```

## SEO

Emit `Offer` structured data alongside the table with
[Structured Data](/components/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 |
| --- | --- |
| <kbd>Tab</kbd> | Move through the plan CTAs in order |

## Pitfalls

- **Do not hard-code the currency into copy.** Pass `price` + `currency` and 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` |

## Further Reading

- [WCAG 1.4.1: Use of Color](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html)
- [schema.org: Offer](https://schema.org/Offer)
