Pricing Table with Monthly Yearly Toggle in Alpine.js
AI generated
x-data
Alpine
Alpine.js · SaaS · Pricing · Frontend
Pricing Table with Monthly Yearly Toggle in Alpine.js
Reactive price recalculation without a page reload

A pricing table with a toggle between monthly and yearly billing is standard for SaaS products and subscription models, because it makes the yearly discount directly visible and eases the purchase decision. With Alpine.js you build a pricing table that recalculates amounts reactively, shows discount badges and highlights the recommended plan, entirely without a page reload and without server side logic in the frontend.

17 min read x-data · x-model · computed getter · x-transition Alpine.js 3.x

1. Why a pricing table with a toggle improves conversion

A pricing table with a monthly yearly toggle solves a classic SaaS problem: a yearly subscription is more economically attractive for the provider, because it lowers churn and secures cash flow up front, but the monthly price feels psychologically cheaper because the absolute number appears smaller. A pricing table with a toggle resolves this conflict by offering both views and framing the switch between them as a deliberate, informed decision by the user.

Technically, a well made pricing table is more than two side by side HTML tables where one is hidden. The toggle should recalculate the amount live while the user operates it, without flickering and without the user having to reload the page. Alpine.js is particularly well suited for this, because reactive price calculation is exactly the strength Alpine was originally built for: small, self contained UI states living directly in the markup.

The following sections build a complete pricing table with reactive price calculation, a discount badge, a highlighted recommended plan and an accessible toggle, ready to drop into any Hyvä theme or any other Alpine based landing page.

2. Foundation: plan data and billing mode in the x-data state

Before the actual implementation begins, it is worth a brief look at the target audience: a pricing table for a B2B SaaS product with a few plans that need explanation requires a different level of detail than a pricing table for a consumer subscription with simple, self explanatory tiers.

The state of a pricing table needs two core parts: an array of individual plans, each with a monthly and yearly price, and a boolean holding the currently selected billing mode. The yearly price is usually not stored as twelve times the monthly price, but as its own, reduced value, because exactly this difference is the discount the pricing table is meant to make visible.

Each plan in the data structure additionally gets a features array and a boolean flag for the recommended plan. This flat, declarative structure keeps the pricing table easy to maintain, because new plans or changed prices only need to be adjusted in the data, without touching markup or calculation logic.


// pricingTable.js — Alpine.data component with plan data
document.addEventListener('alpine:init', () => {
  Alpine.data('pricingTable', () => ({
    yearly: false, // false = monthly billing, true = yearly billing
    plans: [
      { name: 'Starter', monthly: 19, yearly: 190, recommended: false,
        features: ['1 user', '5 projects', 'Email support'] },
      { name: 'Pro', monthly: 49, yearly: 490, recommended: true,
        features: ['10 users', 'Unlimited projects', 'Priority support'] },
      { name: 'Business', monthly: 99, yearly: 990, recommended: false,
        features: ['Unlimited users', 'API access', 'Dedicated contact'] }
    ]
  }));
});

3. Reactive price calculation with computed getters

The displayed price per plan must not be manually recalculated on every toggle click and written into a separate variable, that would spread the logic across several places and create sources of error. Instead, a well built pricing table uses JavaScript getter functions inside the x-data object, which automatically re evaluate as soon as the billing mode changes, because Alpine tracks the dependencies reactively.

For the monthly display of the yearly price, which many pricing tables show additionally to ease comparison, the yearly price is divided by twelve and rounded. This derived value should never be stored in the data array itself, always calculated as a getter, so a pricing table only needs to be adjusted in a single place when a price changes.


Alpine.data('pricingTable', () => ({
  yearly: false,
  plans: [ /* ... plan data ... */ ],

  // Getter recalculates automatically whenever `yearly` changes
  priceFor(plan) {
    return this.yearly ? plan.yearly : plan.monthly;
  },

  // Effective monthly rate when paying yearly — for comparison display
  monthlyEquivalent(plan) {
    return Math.round(plan.yearly / 12);
  },

  get billingLabel() {
    return this.yearly ? 'per year' : 'per month';
  }
}));

Another, easily overlooked point in price calculation concerns rounding errors from floating point numbers. A pricing table that stores prices in cents rather than whole currency units avoids classic JavaScript rounding issues such as 0.1 plus 0.2 not exactly equaling 0.3. For pure display of rounded amounts, Math.round() is sufficient, but for actual payment transactions, rounding should always happen server side in payment processing, not in the frontend.

4. Discount badge: making the yearly advantage visible

A discount badge, usually a small label such as minus twenty percent placed directly next to the toggle, is a central element of every convincing pricing table. The percentage is not hard coded, but calculated from the difference between twelve monthly payments and the actual yearly price, so the badge automatically stays correct even if individual plan prices change later.

For a pricing table with several plans that have different percentage discounts, either a single, averaged discount badge can be shown at the toggle, or an individual badge per plan directly at the price line. The latter is more precise but visually busier, the decision depends on the specific pricing model.


Alpine.data('pricingTable', () => ({
  yearly: false,
  plans: [ /* ... plan data ... */ ],

  // Percentage saved by choosing yearly billing over 12x monthly
  discountPercent(plan) {
    const fullPrice = plan.monthly * 12;
    const saved = fullPrice - plan.yearly;
    return Math.round((saved / fullPrice) * 100);
  }
}));

5. The toggle itself: a toggle switch with x-model

The toggle itself is essentially a checkbox bound directly to the yearly property in the state via x-model. Visually, this usually becomes a pill shaped toggle switch with two labels to the left and right, where the active label is visually highlighted via a dynamic class binding. This binding is the core of the reactivity of the entire pricing table, because as soon as yearly changes, every dependent getter updates automatically.

One detail that is often overlooked: the entire toggle area, including both labels, should be clickable, not just the small visual switch itself. A click area that is too small frustrates users particularly on mobile devices, where touch targets should be at least 44 by 44 pixels, so the pricing table remains comfortably usable on a smartphone too.


<!-- Toggle switch bound directly to the `yearly` state property -->
<div x-data="pricingTable()" class="pricing-toggle">
  <span :class="{ 'font-bold': !yearly }">Monthly</span>

  <button
    type="button"
    role="switch"
    :aria-checked="yearly"
    @click="yearly = !yearly"
    class="toggle-switch"
  >
    <span class="toggle-knob" :class="{ 'translate-x-5': yearly }"></span>
  </button>

  <span :class="{ 'font-bold': yearly }">Yearly</span>
  <span class="discount-badge" x-text="`minus ${discountPercent(plans[1])}%`"></span>
</div>

6. Highlighting the recommended plan without overengineering

Most pricing tables visually highlight a middle plan, usually through a larger border, a badge such as most popular choice, and a slightly raised card position. This pattern works psychologically because it takes a decision off the user's shoulders without forcing it on them, and because middle options in price comparisons are generally chosen above average frequency.

Technically, the highlight is just a conditional class binding based on the recommended flag in the plan object, not a separate component branch. It is important not to overload this highlight with too many extra visual elements, a pricing table with three animated badges per plan quickly feels cluttered rather than trustworthy.

Some pricing tables go a step further and adjust the recommendation dynamically based on the user's behavior, for instance based on the number of projects or team members already in use in an existing customer account. This personalization is technically more involved, because it requires data from the backend, but it can noticeably improve the accuracy of the recommendation compared with a static default set in the frontend.

For most projects, however, the static variant is entirely sufficient. What matters is that the decision of which plan is marked as recommended is based on real usage data or at least a deliberate product decision, and that the most expensive or the cheapest plan is not arbitrarily highlighted just to boost revenue per user in the short term. A pricing table that is meant to build trust must communicate the recommendation in a way that is comprehensible.

7. Remembering the user's choice across the session

If a user has already switched to yearly billing and then navigates to a detail page for a single plan, this choice should be preserved when returning to the pricing table, instead of falling back to the default monthly value. sessionStorage is better suited for this than localStorage, because the preference is usually only relevant for the current visit and does not need to be stored for weeks.

The implementation follows the same basic structure as other Alpine components with persistence: on init() the stored value is read and applied, on every change of the toggle the new value is immediately written back. This keeps the pricing table consistent, even as the user navigates back and forth between several pages.


Alpine.data('pricingTable', () => ({
  yearly: false,
  plans: [ /* ... plan data ... */ ],

  init() {
    // Restore the user's last choice for this browser session
    const stored = sessionStorage.getItem('billing_mode');
    if (stored === 'yearly') this.yearly = true;

    this.$watch('yearly', (value) => {
      sessionStorage.setItem('billing_mode', value ? 'yearly' : 'monthly');
    });
  }
}));

8. Accessibility: the toggle as a real radio group

A visual toggle switch for a pricing table should be marked up semantically either as a checkbox with a clear label or, with more than two options, as a radio group, not as a plain <div> with a click handler. Screen reader users need to be told both the current state and the available options, which native form elements provide automatically, while custom built div buttons need additional ARIA attributes.

In addition, the focus ring on the toggle should stay visible, many design systems accidentally remove outline entirely for a cleaner visual look. For a pricing table that is meant to be usable by keyboard too, a visible focus indicator on the toggle is not optional, it is a basic WCAG requirement.

Besides the pure persistence of the user's choice, it is worth considering, for a pricing table, the behavior when navigating back in the browser. If the page is restored from the bfcache via the browser's back button, the last selected billing mode should also be preserved, instead of jumping back to the default. This can be checked via the pageshow event, which fires with event.persisted === true on a bfcache restoration.

Another, often underestimated advantage of a pricing table with cleanly separated data and calculation logic shows up when A/B testing different pricing models. When prices, discounts and feature lists live exclusively in the data array, a test variant can be created by simply swapping the values, without duplicating markup or calculation logic. This separation pays off in the long run once a product team starts systematically experimenting with different price points.

9. Pricing table implementations compared

There are several common ways to implement a pricing table with a toggle, with substantial differences in interactivity and maintainability.

Approach Interactivity Maintainability Load time
Two static tables with a CSS toggle No real toggle Duplicate price data in HTML Very fast
React component with useState Fully reactive Build step required Additional JS bundle
Pricing table with Alpine.js Fully reactive, minimal code Price data centralized in x-data Alpine already loaded
jQuery toggle with DOM manipulation Works, but hard to follow Prices often duplicated in markup +30 KB jQuery core

The comparison shows that a pricing table with Alpine.js offers the same full reactivity as a React based approach, without requiring an additional build step or a separate JavaScript bundle, because Alpine is already loaded in a Hyvä theme.

Mironsoft

Alpine.js components and conversion optimization for Magento Hyvä shops

A pricing table that actually sells yearly plans?

We build custom Alpine.js components for your Hyvä shop, from pricing tables to testimonial sliders to accessible forms, performant and without unnecessary dependencies.

Component audit

Reviewing existing pricing tables for conversion and accessibility

Custom development

Pricing tables and further marketing widgets with Alpine.js

CRO consulting

Optimizing discount messaging and plan highlighting with data

10. Summary

A good pricing table with a monthly yearly toggle needs a clean separation of plan data and calculation logic, reactive getters instead of manual recalculation, an automatically correct discount badge, a subtly highlighted recommended plan and an accessible toggle. With Alpine.js such a pricing table comes together without a build step and without an additional JavaScript bundle, directly inside an existing Hyvä setup.

The decisive technical advantage lies in the computed getters: when the billing mode changes, every dependent value updates automatically, without prices having to be manually synchronized in several places. Anyone who stores the user's choice across the session and implements the toggle cleanly with native form elements ends up with a pricing table that both converts and remains accessible to every user group.

Pricing Table with Monthly Yearly Toggle — The Essentials at a Glance

Data structure

Plan array with monthly and yearly price, discount calculated from the difference.

Reactivity

Getter methods instead of stored values, automatic recalculation on toggle.

Persistence

sessionStorage remembers the user's choice for the current visit.

Accessibility

Native checkbox or radio group instead of a plain div, visible focus ring.

11. FAQ: Pricing Table with Monthly Yearly Toggle in Alpine.js

1Not just monthly times twelve?
The discount arises exactly from the difference, otherwise there is no incentive.
2Calculating the discount percentage?
Savings divided by the full yearly sum, calculated as a getter.
3Why computed getters?
Stay automatically in sync with the billing mode, no manual update needed.
4How large should the click area be?
At least 44x44 pixels, both labels should be clickable.
5Storing the user's choice?
sessionStorage fits well since it is only relevant for the current visit.
6Individual badge per plan?
More precise with different discounts, but visually busier than a single badge.
7Checkbox instead of div?
Native elements communicate state automatically to screen readers.
8Highlighting without overengineering?
A larger border, one badge, a slightly raised position are enough.
9Keep the focus ring visible?
Yes, a removed outline without replacement violates basic WCAG requirements.
10Build step needed?
No, Alpine.js runs directly in the browser without a compile step.