komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Components & Islands••By Komal Nakrani

An Astro Pricing Table Component

A data-driven Astro pricing table: plans from a typed array, a highlighted plan, feature lists, an optional monthly/yearly toggle, and Product schema.

#Astro#Components#Pricing#Landing Pages

This Astro pricing table is driven by a typed array: each plan has a name, price, description, features and a call to action, and one plan can be highlighted. The cards are plain server-rendered HTML, so the page ships no JavaScript unless you add the optional monthly/yearly toggle, which is about twenty lines of script. Prices live in one data file, so changing them never means editing markup.

Key Takeaways

  • Keep plans in src/data/pricing.ts, not in the component.
  • Use real list markup for features so screen readers announce counts.
  • The billing toggle swaps prices with a script; both prices are rendered in the HTML.
  • Mark the recommended plan with text, not only colour.

The data

// src/data/pricing.ts
export interface Plan {
  name: string;
  monthly: number;
  yearly: number;          // per month, billed yearly
  description: string;
  features: string[];
  cta: { label: string; href: string };
  highlighted?: boolean;
}

export const plans: Plan[] = [
  {
    name: 'Starter',
    monthly: 12,
    yearly: 10,
    description: 'For one site and a small team.',
    features: ['1 site', 'Basic analytics', 'Email support'],
    cta: { label: 'Start with Starter', href: '/signup/?plan=starter' },
  },
  {
    name: 'Pro',
    monthly: 29,
    yearly: 24,
    description: 'For growing teams with several sites.',
    features: ['10 sites', 'Advanced analytics', 'Priority support', 'Custom domains'],
    cta: { label: 'Start with Pro', href: '/signup/?plan=pro' },
    highlighted: true,
  },
];

The component

---
// src/components/PricingTable.astro
import type { Plan } from '../data/pricing';
interface Props { plans: Plan[]; currency?: string }
const { plans, currency = '$' } = Astro.props;
---
<div class="pricing" data-billing="monthly">
  <div class="billing-toggle" role="group" aria-label="Billing period">
    <button type="button" data-period="monthly" aria-pressed="true">Monthly</button>
    <button type="button" data-period="yearly" aria-pressed="false">Yearly</button>
  </div>

  <ul class="plans">
    {plans.map((plan) => (
      <li class:list={['plan', { 'plan-highlighted': plan.highlighted }]}>
        {plan.highlighted && <p class="plan-badge">Most popular</p>}
        <h3>{plan.name}</h3>
        <p class="plan-desc">{plan.description}</p>
        <p class="plan-price">
          <span class="price-monthly">{currency}{plan.monthly}</span>
          <span class="price-yearly">{currency}{plan.yearly}</span>
          <span class="price-unit">/ month</span>
        </p>
        <ul class="plan-features">
          {plan.features.map((f) => <li>{f}</li>)}
        </ul>
        <a class="plan-cta" href={plan.cta.href}>{plan.cta.label}</a>
      </li>
    ))}
  </ul>
</div>

<style>
  .plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.5rem; list-style: none; padding: 0; }
  .plan { display: flex; flex-direction: column; padding: 1.75rem; border: 1px solid var(--color-border, #e5e4df); border-radius: 0.75rem; }
  .plan-highlighted { border: 2px solid var(--color-brand, #2563eb); }
  .plan-badge { align-self: start; margin: 0 0 0.75rem; padding: 0.15rem 0.6rem; font-size: 0.8rem; border-radius: 999px; background: var(--color-brand, #2563eb); color: #fff; }
  .plan h3 { margin: 0; }
  .plan-price { font-size: 2.25rem; font-weight: 700; margin: 1rem 0; }
  .price-unit { font-size: 1rem; font-weight: 400; }
  .plan-features { flex: 1; padding-left: 1.1rem; }
  .plan-cta { display: block; text-align: center; padding: 0.75rem; border-radius: 0.5rem; background: var(--color-text, #14140f); color: #fff; text-decoration: none; }
  .billing-toggle { display: inline-flex; gap: 0.25rem; margin-bottom: 1.5rem; }
  .billing-toggle button[aria-pressed='true'] { font-weight: 700; text-decoration: underline; }

  .pricing[data-billing='monthly'] .price-yearly,
  .pricing[data-billing='yearly'] .price-monthly { display: none; }
</style>

<script>
  document.querySelectorAll<HTMLElement>('.pricing').forEach((root) => {
    const buttons = root.querySelectorAll<HTMLButtonElement>('[data-period]');
    buttons.forEach((btn) =>
      btn.addEventListener('click', () => {
        root.dataset.billing = btn.dataset.period;
        buttons.forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
      }),
    );
  });
</script>

Using it

---
import PricingTable from '../components/PricingTable.astro';
import { plans } from '../data/pricing';
---
<PricingTable plans={plans} />

If you only have one billing period, delete the toggle markup, the two .pricing[data-billing] rules and the script; the component then ships zero JavaScript.

Structured data

For a product with published prices, Product schema with offers helps search engines show pricing. Render it with set:html as shown in the Astro SEO checklist. Only mark up prices that are actually shown on the page.

Accessibility notes

  • “Most popular” is text, so it’s announced, not just a coloured border.
  • The billing toggle uses aria-pressed on real buttons.
  • Feature lists are <ul>, so screen readers announce the number of items.

For more landing-page sections, see the Astro components guide. SaaS sites with pricing pages already built are in technology themes. Google’s Product structured data docs cover the schema.

FAQ

Can prices come from a CMS?

Yes. Load them in a content collection or fetch them in frontmatter, and pass the same Plan[] shape to the component.

Does the toggle hurt SEO?

No. Both prices are in the HTML; CSS shows one at a time.