Component · Pricing tables
Pricing Comparison
Vue 3 plan comparison table with grouped feature rows, check and dash cells, a sticky plan header on desktop and stacked cards on mobile. Tailwind CSS.
Three-tier Vue 3 pricing table with a monthly/yearly toggle, a highlighted plan and a select event. Tailwind CSS, dark mode, fully typed props.
by Vueplayv1.0.0Updated 3 hours ago
A pricing section in the shape most SaaS products need: three plans side by side, the middle one highlighted, and a billing switch that recalculates prices as the visitor toggles.
Yearly prices can be set per plan or derived from a single discount, and the component emits select with the plan and billing period so you can route to checkout however you like. The toggle is an accessible radio group.
<script setup lang="ts">
import { computed, ref } from 'vue'
export interface PricingPlan {
id: string
name: string
description: string
/** Price per month when billed monthly, in whole currency units. */
monthly: number
/** Price per month when billed yearly. Omit to derive it from `yearlyDiscount`. */
yearly?: number
features: string[]
cta?: string
featured?: boolean
}
const props = withDefaults(defineProps<{
plans?: PricingPlan[]
heading?: string
subheading?: string
currency?: string
/** Fraction taken off the monthly price when billed yearly, used when a plan has no `yearly`. */
yearlyDiscount?: number
defaultBilling?: 'monthly' | 'yearly'
}>(), {
heading: 'Simple pricing that grows with you',
subheading: 'Start free, upgrade when your team needs more. Every plan includes unlimited projects.',
currency: '$',
yearlyDiscount: 0.2,
defaultBilling: 'monthly',
plans: () => [
{ id: 'starter', name: 'Starter', description: 'For side projects and trying things out.', monthly: 0, features: ['Up to 3 projects', 'Community support', 'Basic analytics'], cta: 'Get started' },
{ id: 'pro', name: 'Pro', description: 'For professionals shipping real products.', monthly: 24, features: ['Unlimited projects', 'Custom domains', 'Priority email support', 'Advanced analytics'], cta: 'Start free trial', featured: true },
{ id: 'team', name: 'Team', description: 'For teams that collaborate every day.', monthly: 49, features: ['Everything in Pro', 'Shared workspaces', 'Roles and permissions', 'SSO and audit log'], cta: 'Contact sales' },
],
})
const emit = defineEmits<{
select: [plan: PricingPlan, billing: 'monthly' | 'yearly']
}>()
const billing = ref<'monthly' | 'yearly'>(props.defaultBilling)
const priced = computed(() => props.plans.map((plan) => {
const perMonth = billing.value === 'monthly' ? plan.monthly : (plan.yearly ?? Math.round(plan.monthly * (1 - props.yearlyDiscount)))
return { plan, perMonth }
}))
</script>
<template>
<section class="px-6 py-20 sm:py-24">
<div class="mx-auto max-w-2xl text-center">
<h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
<p class="mt-4 text-lg text-gray-600 dark:text-gray-400">{{ subheading }}</p>
</div>
<div class="mt-10 flex justify-center">
<div role="radiogroup" aria-label="Billing period" class="inline-flex rounded-full bg-gray-100 p-1 text-sm font-medium dark:bg-gray-800">
<button
v-for="option in (['monthly', 'yearly'] as const)"
:key="option"
type="button"
role="radio"
:aria-checked="billing === option"
class="rounded-full px-4 py-2 transition"
:class="billing === option ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-950 dark:text-white' : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'"
@click="billing = option"
>
{{ option === 'monthly' ? 'Monthly' : 'Yearly' }}
<span v-if="option === 'yearly' && yearlyDiscount > 0" class="ml-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400">
−{{ Math.round(yearlyDiscount * 100) }}%
</span>
</button>
</div>
</div>
<div class="mx-auto mt-12 grid max-w-6xl gap-6 lg:grid-cols-3">
<article
v-for="{ plan, perMonth } in priced"
:key="plan.id"
class="relative flex flex-col rounded-2xl border p-8"
:class="plan.featured
? 'border-indigo-600 bg-gray-900 text-white shadow-xl shadow-indigo-600/10 dark:border-indigo-400 dark:bg-gray-900'
: 'border-gray-200 bg-white text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-white'"
>
<span v-if="plan.featured" class="absolute -top-3 left-8 rounded-full bg-indigo-600 px-3 py-1 text-xs font-semibold text-white dark:bg-indigo-500">Most popular</span>
<h3 class="text-lg font-semibold">{{ plan.name }}</h3>
<p class="mt-2 text-sm" :class="plan.featured ? 'text-gray-300' : 'text-gray-600 dark:text-gray-400'">{{ plan.description }}</p>
<p class="mt-6 flex items-baseline gap-1">
<span class="text-4xl font-bold tracking-tight">{{ perMonth === 0 ? 'Free' : `${currency}${perMonth}` }}</span>
<span v-if="perMonth > 0" class="text-sm" :class="plan.featured ? 'text-gray-400' : 'text-gray-500'">/ month</span>
</p>
<p class="mt-1 h-5 text-xs" :class="plan.featured ? 'text-gray-400' : 'text-gray-500'">
<template v-if="perMonth > 0 && billing === 'yearly'">{{ currency }}{{ perMonth * 12 }} billed yearly</template>
</p>
<ul class="mt-6 flex-1 space-y-3 text-sm">
<li v-for="feature in plan.features" :key="feature" class="flex gap-3">
<svg viewBox="0 0 20 20" aria-hidden="true" class="size-5 shrink-0" :class="plan.featured ? 'fill-indigo-400' : 'fill-indigo-600 dark:fill-indigo-400'">
<path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.6l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" />
</svg>
{{ feature }}
</li>
</ul>
<button
type="button"
class="mt-8 rounded-xl px-4 py-2.5 text-sm font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
:class="plan.featured
? 'bg-indigo-500 text-white hover:bg-indigo-400'
: 'bg-gray-900 text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200'"
@click="emit('select', plan, billing)"
>
{{ plan.cta ?? 'Choose plan' }}
</button>
</article>
</div>
</section>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| plans | PricingPlan[] | Starter, Pro, Team | Plans to show. Each has id, name, description, monthly price, optional yearly price, features, cta label and featured flag. |
| heading | string | 'Simple pricing that grows with you' | Section heading. |
| subheading | string | 'Start free, upgrade when…' | Supporting text under the heading. |
| currency | string | '$' | Currency symbol put before prices. |
| yearlyDiscount | number | 0.2 | Discount applied to the monthly price for yearly billing when a plan has no explicit yearly price. |
| defaultBilling | 'monthly' | 'yearly' | 'monthly' | Billing period selected on first render. |
| Event | Payload | Description |
|---|---|---|
| select | (plan: PricingPlan, billing: 'monthly' | 'yearly') | Fired when a plan's button is clicked. |
Component · Pricing tables
Vue 3 plan comparison table with grouped feature rows, check and dash cells, a sticky plan header on desktop and stacked cards on mobile. Tailwind CSS.
Component · Pricing tables
Single-plan Vue 3 pricing card with a feature checklist, a sale price, a money-back guarantee and one clear CTA. Tailwind CSS, dark mode, typed props.
Component · Hero sections
Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.
Component · Feature sections
Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.