Skip to main content
WireKit
Copy for LLM

Pricing Table

The plan grid every product page needs. Price formats a single amount; this is the block people choose from.

Basic Usage

Three plans
  • 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.

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:

Monthly / annual toggle
  • 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. 490049), 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 + 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

Was this page helpful?

Voting requires cookies or local storage. What we store