A SaaS pricing page should let a visitor compare plans, choose one with a keyboard, and submit the choice through a normal form. Use native radio buttons inside a fieldset, keep visible labels, and treat the styled pricing cards as an enhancement of that form.
This gives you browser behavior, keyboard support, and a value that can be validated on the server without rebuilding a selection control from generic div elements.
Start with the form, not the cards
Each plan is one option in a single choice. That maps directly to radio buttons that share the same name.
<form action="/signup" method="post" class="pricing-form">
<fieldset class="plans">
<legend>Choose the plan that fits your team</legend>
<label class="plan-card">
<input type="radio" name="plan" value="starter" checked />
<span class="plan-name">Starter</span>
<span class="plan-price">$19 per month</span>
<span>For one person testing a repeatable workflow.</span>
</label>
<label class="plan-card">
<input type="radio" name="plan" value="team" />
<span class="plan-name">Team</span>
<span class="plan-price">$59 per month</span>
<span>For a small team sharing work and permissions.</span>
</label>
<label class="plan-card">
<input type="radio" name="plan" value="business" />
<span class="plan-name">Business</span>
<span class="plan-price">$149 per month</span>
<span>For teams that need controls and priority support.</span>
</label>
</fieldset>
<button type="submit">Continue with selected plan</button>
</form>
MDN recommends grouping a set of radio buttons with fieldset; the nested legend gives the group a caption. The shared name="plan" also means the browser submits one selected value.
Keep every plan label clickable
Wrapping the radio input inside its label makes the whole card part of the control. A visitor can click the text, price, or empty space inside the label.
Keep the input available to assistive technology. Do not use display: none on it. You can place it visually and style the card from its state.
.plans {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
border: 0;
padding: 0;
}
.plans legend {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1rem;
}
.plan-card {
position: relative;
display: grid;
gap: 0.5rem;
padding: 1.25rem;
border: 2px solid #d6d6d6;
border-radius: 0.75rem;
cursor: pointer;
}
.plan-card:has(input:checked) {
border-color: #5b45e0;
background: #f6f4ff;
}
.plan-card:has(input:focus-visible) {
outline: 3px solid #1a73e8;
outline-offset: 3px;
}
.plan-name,
.plan-price {
font-weight: 700;
}
Do not make color the only selected-state signal. Keep the radio control visible, add a border change, and make the keyboard focus easy to see.
If you need to support browsers without :has(), add a small progressive enhancement that toggles a class. The radio buttons should still work without it.
Put price details next to the choice
The visitor should not have to remember what a plan includes while choosing it.
Each card should answer:
- What does it cost, including the billing period?
- Who is it for?
- What limit changes between plans?
- Which important feature is included or excluded?
- What happens after the visitor continues?
Use the same order for each card. Avoid putting the critical difference in a tooltip. Tooltips are easy to miss on touch screens and during keyboard use.
If monthly and annual billing change the displayed price, keep the billing period in the visible text and in the submitted data. A number without its period can create a false comparison.
Use JavaScript for feedback, not basic selection
You may want the button to repeat the selected plan. Read the checked radio instead of creating a second source of truth.
const form = document.querySelector('.pricing-form');
const button = form.querySelector('button[type="submit"]');
function updateButton() {
const selected = form.elements.plan.value;
const label = selected[0].toUpperCase() + selected.slice(1);
button.textContent = `Continue with ${label}`;
}
form.addEventListener('change', updateButton);
updateButton();
The form remains usable if this script fails. JavaScript changes the feedback, while HTML keeps the selection and submission working.
Validate the plan on the server
The browser sends a string chosen by the client. Treat it as untrusted input.
const allowedPlans = new Set(['starter', 'team', 'business']);
if (!allowedPlans.has(request.body.plan)) {
return response.status(400).send('Choose a valid plan.');
}
Look up the current price and entitlements on the server. Do not accept a price from a hidden field or query string. A visitor can edit client-side values before submitting the form.
Test the complete pricing path
Before saving the page, test these cases:
- Tab to the selected option and move through the group with arrow keys.
- Zoom the page and confirm the cards reflow without hiding content.
- Click every part of each card.
- Submit each plan and confirm the server receives the expected value.
- Disable JavaScript and confirm selection and submission still work.
- Use a screen reader to check that the group legend and plan labels are announced clearly.
A pricing page is a decision form. Native controls give that decision a reliable base, while the visual cards help people compare the options.
Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.
Subscribe for more stories on growing your audience by building in public.
Join us on Buildside: the social network for founders building in public.
Top comments (1)
Starting from a real form is the right order. It also makes the page work before the JS bundle loads, which matters more than people think on a slow launch day.
Two additions that come up on almost every pricing page:
name="billing") inside the same form, not a JS toggle that only swaps the displayed prices. The server then gets both values, and the billing period can't silently default to monthly because of a hydration hiccup./pricing?plan=team) and keep the checked state when the user comes back from signup with the back button. Browsers restore form state for native radios on back navigation, which is one more free win from not rebuilding the control out of divs.