Building a Pricing Page: Layout, Psychology and Schema
A practitioner’s guide to pricing page design in 2026: grid layout that survives content changes, accessible pricing table HTML, billing toggles without CLS,
Every pricing page we’ve ever rebuilt for a client had the same three problems: too many tiers, a comparison table nobody could read on a phone, and prices injected by JavaScript after first paint so Google saw an empty card and the user saw a 0.24 layout shift.
Pricing page design gets treated as a styling job. It isn’t. It’s the one page where layout, copy, markup semantics and structured data all have to agree with each other, because the page has a single job: help someone who already wants to buy pick a tier without opening a support chat.
Here’s how we build them now, including the parts that are genuinely contested.
- Render every price variant in the HTML at build time and toggle visibility with CSS. Fetching prices client-side costs you both crawlability and a measurable CLS hit on slow connections.
- Use cards for the tier selector and a real
<table>with<th scope="col">for the feature matrix. A div grid withrole="table"is worse than either. - FAQ rich results have been restricted to government and health sites since Google’s August 2023 change, so
FAQPagemarkup on a pricing page is for machine readability and AI answer engines, not for SERP stars. - For SaaS,
SoftwareApplicationwith anoffersarray andUnitPriceSpecificationdescribes recurring pricing correctly.Productmarkup on a non-transactional page is a spam signal, not a shortcut. - Three tiers beats four in almost every case we’ve tested, and a fourth “Enterprise, contact us” card only earns its place if it carries a price floor.
The page has one job, and it isn’t persuasion
People arriving at your pricing page are already down-funnel. They’ve read the homepage, maybe a feature page, and they’re now doing arithmetic. Your job is to make the arithmetic fast and to remove the two questions that cause abandonment: “which one am I?” and “what happens when I outgrow it?”
That reframing kills a lot of common elements. The hero with a stock photo of a handshake. The long value proposition above the tiers. The testimonial carousel wedged between the cards and the FAQ. All of it pushes the prices below the fold on a 1366×768 laptop, which is still a real resolution in corporate environments.
Our rule: prices visible without scrolling on a 768px-tall viewport. Everything else goes underneath.

Pricing page design: the grid that actually works
Three cards, equal height, middle one highlighted. It’s boring and it wins. Here’s the layout that survives content changes:
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
align-items: stretch; / equal height cards without JS /
}
.pricing-card {
display: flex;
flex-direction: column;
}
.pricing-card .cta {
margin-top: auto; / buttons align on the same baseline regardless of feature count /
}
/ Highlight without scaling: transform: scale() blurs text on non-retina displays /
.pricing-card--featured {
border: 2px solid var(--brand);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.12);
}
Two opinions worth defending. First, don’t use transform: scale(1.05) on the featured card. It creates a new stacking context, blurs text on 1x displays, and makes the card overlap its neighbours on narrow viewports. Use border weight and elevation instead.
Second, auto-fit with minmax handles the breakpoint for you. At 280px minimum, three cards need roughly 900px of container width before they wrap, which is about where you’d have put a media query anyway. If your cards are misbehaving at intermediate widths, the container is usually the culprit, not the grid. Our CSS layout debugging guide covers the inspection order we use.
The mobile ordering trade-off nobody mentions
On desktop the popular plan sits in the middle. On mobile you want it first. If you do that with order: -1, visual order and DOM order diverge, and keyboard users tab through the middle card first on desktop. That’s a WCAG 1.3.2 meaningful sequence concern.
We accept the mismatch, because each card is a self-contained unit and the reading order still makes sense. What we don’t accept is reordering individual elements inside a card. If the price appears above the plan name visually but below it in the DOM, screen reader output becomes nonsense.
Pricing table HTML that screen readers can actually parse
The tier cards aren’t tabular data. They’re a set of options, so mark them up as a list of articles, not a table:
<ul class="pricing-grid" role="list">
<li class="pricing-card pricing-card--featured">
<p class="badge" id="popular-pro">Most popular</p>
<h3 id="plan-pro">Pro</h3>
<p class="price">
<span class="amount" data-monthly="49" data-annual="39">49</span>
<span class="period">USD per user, per month</span>
</p>
<ul class="features">
<li>Unlimited projects</li>
<li>10 GB storage</li>
<li>Priority email support, 12h response</li>
</ul>
<a class="cta btn" href="/signup?plan=pro"
aria-describedby="plan-pro popular-pro">Start 14-day trial</a>
</li>
</ul>
Note aria-describedby on the CTA. Without it, a screen reader user tabbing between buttons hears “Start 14-day trial” three times with no indication of which plan. That single attribute is the highest-value accessibility fix on a typical pricing page.
The full feature comparison is different. That genuinely is tabular data, so use a table:
<table class="comparison">
<caption>Full feature comparison across all plans</caption>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Starter</th>
<th scope="col">Pro</th>
<th scope="col">Business</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">SSO / SAML</th>
<td><span aria-hidden="true">×</span><span class="sr-only">Not included</span></td>
<td><span aria-hidden="true">×</span><span class="sr-only">Not included</span></td>
<td><span aria-hidden="true">✓</span><span class="sr-only">Included</span></td>
</tr>
</tbody>
</table>
A bare tick glyph reads as “check mark” or, worse, as nothing at all depending on the screen reader. The visually hidden text costs you four words of markup per cell and makes the table usable. On mobile, wrap the table in a container with overflow-x: auto and tabindex="0" so it’s keyboard scrollable, and add position: sticky to the first column. Stacking a comparison table into cards on mobile destroys the comparison, which was the entire point.
If you’d rather not build this from scratch, the Canvas HTML template ships pricing table components on Bootstrap 5 with the semantics already sorted, including the sticky comparison variant.
The monthly and annual toggle, without the layout shift
The classic bug: the toggle swaps “$49” for “$490” and the card width jumps, dragging its neighbours with it. Two fixes, both cheap.
.price .amount {
font-variant-numeric: tabular-nums; / all digits share one advance width /
display: inline-block;
min-width: 3ch; / reserve room for the widest price you sell /
}
const toggle = document.querySelector('#billing-toggle');
const amounts = document.querySelectorAll('.price .amount');
toggle.addEventListener('change', () => {
const cycle = toggle.checked ? 'annual' : 'monthly';
amounts.forEach(el => { el.textContent = el.dataset[cycle]; });
// Announce the change; a silent price swap is invisible to screen readers
document.querySelector('#billing-status').textContent =
Showing ${cycle} pricing;
});
<p id="billing-status" role="status" class="sr-only"></p>
The data attributes matter for more than convenience. Both price variants exist in the HTML at first paint, so a crawler and an AI answer engine can read your annual pricing without executing the toggle. If you fetch prices from an API on load, neither can, and you’ve added a network round trip to the page’s most important content. We’ve seen that pattern cost a client their pricing snippet in AI Overviews entirely.
Set the default to whichever cycle you want anchored. Annual-default with a “save 20%” badge is standard. Just make sure the monthly equivalent is still stated in words. “$39/mo billed annually” and “$39/mo” are different products, and burying that distinction generates refund requests.
Anchoring, decoys and the honest version
Price anchoring works. A higher-priced tier next to your target tier makes the target feel reasonable, and that effect is well documented in behavioural pricing research. What’s less discussed is how badly it backfires when the anchor is obviously fake.
A decoy tier that nobody buys and that offers no coherent value is transparent to technical buyers. Your audience reads pricing pages for a living. If your Business tier is identical to Pro plus SSO at triple the price, say so plainly and let enterprises who need SSO pay for it. Don’t dress it up with invented feature names.
Things that consistently move the needle on client sites:
- Per-unit clarity. “$49 per user per month” beats “$49/mo” every time because the buyer can immediately compute their team’s cost.
- A price floor on the Enterprise card. “From $2,000 per month” filters out the wrong leads and stops your sales team burning hours on a five-person startup.
- Naming the constraint, not the tier. “Up to 10,000 monthly active users” tells someone which plan they are. “Growth” doesn’t.
- Cancellation terms near the CTA. One line: “Cancel anytime, no card required for the trial.” It removes the biggest silent objection.
Things that don’t: countdown timers on subscription pricing, fake scarcity, and strikethrough “was $99” pricing that has been there for eight months. That last one now attracts regulatory attention in the EU and UK under price transparency rules.
Pricing page SEO and the schema that actually applies
Most guides tell you to slap Product schema on the page. For a SaaS pricing page that’s wrong, and Google’s structured data guidelines are explicit that Product markup belongs on pages describing a specific purchasable product with offers. A tier comparison page usually isn’t that.
For software, use SoftwareApplication with an offers array:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Acme Analytics",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"url": "https://example.com/pricing",
"offers": [
{
"@type": "Offer",
"name": "Starter",
"price": "19.00",
"priceCurrency": "USD",
"url": "https://example.com/pricing#starter",
"availability": "https://schema.org/InStock",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "19.00",
"priceCurrency": "USD",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": 1,
"unitCode": "MON"
}
}
},
{
"@type": "Offer",
"name": "Pro",
"price": "49.00",
"priceCurrency": "USD",
"url": "https://example.com/pricing#pro",
"availability": "https://schema.org/InStock"
}
]
}
</script>
unitCode: "MON" is the UN/CEFACT code for month, and it’s how you express “per month” in a way a parser understands. For agency or consulting pricing, use Service with an Offer. For physical goods, Product is correct and you should also meet the merchant listing requirements.
Be realistic about the payoff. Schema on a pricing page rarely produces a visible rich result in classic blue links. What it does do is feed the structured understanding that AI answer surfaces use when someone asks “how much does X cost”, and that traffic is now material. We’ve written more on which signals search engines reward versus penalise, and the short version applies here: accurate markup that matches visible content helps, markup that claims prices you don’t show is a manual action waiting to happen.
Validate with Schema.org’s own validator rather than only Google’s Rich Results Test. The Rich Results Test only reports on types Google renders, so it’ll show “no eligible items” for perfectly valid SoftwareApplication markup and send you on a wild goose chase.
Instrument it, or you’re guessing
Pageviews are not the metrics that matter here. Track these four:
- Tier CTA click distribution. If 80% of clicks go to Starter, your middle tier isn’t doing its job or your Starter is too generous.
- Toggle interaction rate. Low usage means your default cycle is right. High usage with a drop-off afterwards means the annual price surprised someone.
- Comparison table scroll depth. If nobody reaches it, move the features people actually ask about up into the cards.
- Contact-sales form completion. Every extra field costs you submissions. For the Enterprise tier we typically ship a four field form on a static page using WebForms, which posts to email and Slack without any backend.
Also watch CLS specifically for this URL in your field data. A pricing page with a font swap, a sticky header and a toggle has three independent ways to shift, and aggregate site-level Core Web Vitals will hide it. If your theme is the source of the instability, that’s a deeper problem. The way we evaluate themes for Core Web Vitals applies directly.
Frequently Asked Questions
How many pricing tiers should I have?
Three, in almost every case. Two makes the choice feel binary and removes your anchor; four or more pushes people into comparison paralysis and forces the cards below 250px wide on tablets. Add a fourth Enterprise card only if it carries a price floor and a genuinely different contract model.
Should I hide prices and use “contact us” instead?
Only if your median deal is genuinely bespoke and above roughly five figures annually. Hiding prices for a self-serve product is the single most reliable way to lose qualified traffic, because the buyer simply opens a competitor’s tab. If you must gate it, publish a starting price so people can self-qualify.
Does FAQPage schema still get rich results?
Not for most sites. Google restricted FAQ rich results to well-known government and health authority sites in August 2023 and that has not been reversed. Keep the markup anyway, because it’s cheap and it gives answer engines clean question and answer pairs to quote, but don’t expect SERP real estate from it.
Where do currency and VAT go?
State the currency next to the number, not just in a footnote, and say explicitly whether the price excludes tax. Geo-detecting currency is fine as a default but always give a manual selector, because IP detection gets travellers and VPN users wrong constantly. If you switch currency client-side, keep the base currency in your JSON-LD rather than swapping it.
Is a comparison table worth building?
Yes, if you have more than about six differentiating features. Below that, put everything in the cards and skip the table entirely. The table exists to serve the buyer who has already narrowed to two tiers and needs one specific answer, so make it searchable with the browser’s find function by keeping the feature names as plain text, not icons.
What to do next
Open your pricing page on a 1366×768 window and check whether the prices are visible without scrolling. Then disable JavaScript and check whether the prices are still in the DOM. Those two tests catch the majority of what’s wrong with most pricing pages, and both take under a minute.
Fix those first. Then add the aria-describedby on your CTAs, then the schema. The layout and the semantics have to be right before structured data does anything useful, because markup that describes a broken page just describes it more accurately.


