Component · Pricing tables
Pricing Tiers
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.
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.
by Vueplayv1.0.0Updated 3 hours ago
The detailed pricing table that sits under your plan cards: every feature down the side, every plan across the top, grouped into sections like Publishing or Security. Cells can be a check, a dash or a specific value such as "200 GB".
On large screens it is a real, semantic <table> with row and column headers, and the plan row sticks to the top as the visitor scrolls so they never lose track of which column is which. Use stickyTop to clear a fixed navbar. Below the lg breakpoint the table becomes one card per plan with its features listed underneath, which is far easier to read on a phone than a sideways-scrolling grid.
Plans and sections are plain typed data, and each plan's button emits select with the plan so you can open checkout or a sales form.
<script setup lang="ts">
export interface ComparisonPlan {
id: string
name: string
/** Display price, e.g. "$0" or "Custom". */
price: string
/** Shown after the price, e.g. "/ month". */
period?: string
description?: string
cta?: string
featured?: boolean
}
/** true = included, false = not included, string = a specific value such as "10 GB". */
export type ComparisonValue = boolean | string
export interface ComparisonFeature {
name: string
/** Optional clarifying note shown under the feature name. */
hint?: string
/** Keyed by plan id. Missing keys count as not included. */
values: Record<string, ComparisonValue>
}
export interface ComparisonSection {
name: string
features: ComparisonFeature[]
}
withDefaults(defineProps<{
plans?: ComparisonPlan[]
sections?: ComparisonSection[]
heading?: string
subheading?: string
/** Distance from the top of the viewport for the sticky header row, e.g. to clear a fixed navbar. */
stickyTop?: string
}>(), {
heading: 'Compare every plan',
subheading: 'All plans include unlimited viewers, SSL on custom domains and daily backups. Pick the one that fits how your team works.',
stickyTop: '0px',
plans: () => [
{ id: 'hobby', name: 'Hobby', price: '$0', period: '/ month', description: 'For personal sites.', cta: 'Start for free' },
{ id: 'studio', name: 'Studio', price: '$19', period: '/ month', description: 'For freelancers and small teams.', cta: 'Start trial', featured: true },
{ id: 'agency', name: 'Agency', price: '$59', period: '/ month', description: 'For client work at scale.', cta: 'Start trial' },
{ id: 'enterprise', name: 'Enterprise', price: 'Custom', description: 'For regulated organisations.', cta: 'Talk to sales' },
],
sections: () => [
{
name: 'Publishing',
features: [
{ name: 'Sites', values: { hobby: '1', studio: '10', agency: 'Unlimited', enterprise: 'Unlimited' } },
{ name: 'Custom domains', values: { hobby: false, studio: true, agency: true, enterprise: true } },
{ name: 'Bandwidth', hint: 'Per month, across all sites', values: { hobby: '10 GB', studio: '200 GB', agency: '1 TB', enterprise: 'Custom' } },
{ name: 'Scheduled publishing', values: { hobby: false, studio: true, agency: true, enterprise: true } },
],
},
{
name: 'Collaboration',
features: [
{ name: 'Editors', values: { hobby: '1', studio: '5', agency: '25', enterprise: 'Unlimited' } },
{ name: 'Comments and review', values: { hobby: false, studio: true, agency: true, enterprise: true } },
{ name: 'Client handoff workspaces', values: { hobby: false, studio: false, agency: true, enterprise: true } },
{ name: 'Roles and permissions', values: { hobby: false, studio: 'Basic', agency: 'Advanced', enterprise: 'Advanced' } },
],
},
{
name: 'Security and support',
features: [
{ name: 'Password-protected pages', values: { hobby: false, studio: true, agency: true, enterprise: true } },
{ name: 'SAML single sign-on', values: { hobby: false, studio: false, agency: false, enterprise: true } },
{ name: 'Audit log retention', values: { hobby: false, studio: false, agency: '30 days', enterprise: '1 year' } },
{ name: 'Support', values: { hobby: 'Community', studio: 'Email', agency: 'Priority', enterprise: 'Dedicated manager' } },
],
},
],
})
const emit = defineEmits<{
select: [plan: ComparisonPlan]
}>()
function valueFor(feature: ComparisonFeature, planId: string): ComparisonValue {
return feature.values[planId] ?? false
}
</script>
<template>
<section class="px-6 py-20 sm:py-24 lg:px-8">
<div class="mx-auto max-w-2xl text-center">
<h2 class="text-3xl font-bold tracking-tight text-balance text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
<p class="mt-4 text-lg text-pretty text-gray-600 dark:text-gray-400">{{ subheading }}</p>
</div>
<!-- Mobile / tablet: one stacked card per plan -->
<div class="mx-auto mt-12 max-w-md space-y-8 lg:hidden">
<article
v-for="plan in plans"
:key="plan.id"
class="rounded-2xl border p-6"
:class="plan.featured ? 'border-sky-500 bg-sky-50/50 dark:border-sky-400 dark:bg-sky-400/5' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900'"
>
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ plan.name }}</h3>
<span v-if="plan.featured" class="rounded-full bg-sky-100 px-2.5 py-0.5 text-xs font-semibold text-sky-800 dark:bg-sky-400/15 dark:text-sky-300">Most popular</span>
</div>
<p v-if="plan.description" class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ plan.description }}</p>
<p class="mt-4 flex items-baseline gap-1">
<span class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">{{ plan.price }}</span>
<span v-if="plan.period" class="text-sm text-gray-500 dark:text-gray-400">{{ plan.period }}</span>
</p>
<button
type="button"
class="mt-5 w-full rounded-lg px-4 py-2.5 text-sm font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
:class="plan.featured ? 'bg-sky-600 text-white hover:bg-sky-500 dark:bg-sky-500 dark:text-gray-950 dark:hover:bg-sky-400' : 'bg-white text-gray-900 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:hover:bg-gray-700'"
@click="emit('select', plan)"
>
{{ plan.cta ?? 'Choose plan' }}
</button>
<div v-for="section in sections" :key="section.name" class="mt-8">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ section.name }}</h4>
<dl class="mt-3 divide-y divide-gray-100 text-sm dark:divide-gray-800">
<div v-for="feature in section.features" :key="feature.name" class="flex items-center justify-between gap-4 py-2.5">
<dt class="text-gray-600 dark:text-gray-400">{{ feature.name }}</dt>
<dd class="shrink-0 text-right">
<template v-if="valueFor(feature, plan.id) === true">
<svg viewBox="0 0 20 20" aria-hidden="true" class="ml-auto size-5 fill-sky-600 dark:fill-sky-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>
<span class="sr-only">Included</span>
</template>
<template v-else-if="valueFor(feature, plan.id) === false">
<span aria-hidden="true" class="text-gray-300 dark:text-gray-600">—</span>
<span class="sr-only">Not included</span>
</template>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ valueFor(feature, plan.id) }}</span>
</dd>
</div>
</dl>
</div>
</article>
</div>
<!-- Desktop: full comparison table with a sticky plan header -->
<div class="mx-auto mt-16 hidden max-w-6xl lg:block">
<table class="w-full table-fixed border-separate border-spacing-0 text-left text-sm">
<caption class="sr-only">Feature comparison across {{ plans.length }} plans</caption>
<colgroup>
<col class="w-1/4">
<col v-for="plan in plans" :key="plan.id">
</colgroup>
<thead class="sticky z-10" :style="{ top: stickyTop }">
<tr>
<td class="border-b border-gray-200 bg-white/90 pb-6 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90" />
<th
v-for="plan in plans"
:key="plan.id"
scope="col"
class="border-b border-gray-200 bg-white/90 px-4 pt-6 pb-6 align-top font-normal backdrop-blur dark:border-gray-800 dark:bg-gray-950/90"
>
<div class="flex items-center gap-2">
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ plan.name }}</span>
<span v-if="plan.featured" class="rounded-full bg-sky-100 px-2 py-0.5 text-[11px] font-semibold text-sky-800 dark:bg-sky-400/15 dark:text-sky-300">Popular</span>
</div>
<p class="mt-2 flex items-baseline gap-1">
<span class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">{{ plan.price }}</span>
<span v-if="plan.period" class="text-xs text-gray-500 dark:text-gray-400">{{ plan.period }}</span>
</p>
<button
type="button"
class="mt-4 w-full rounded-lg px-3 py-2 text-sm font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
:class="plan.featured ? 'bg-sky-600 text-white hover:bg-sky-500 dark:bg-sky-500 dark:text-gray-950 dark:hover:bg-sky-400' : 'bg-white text-gray-900 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 dark:bg-gray-900 dark:text-white dark:ring-gray-700 dark:hover:bg-gray-800'"
@click="emit('select', plan)"
>
{{ plan.cta ?? 'Choose plan' }}
</button>
</th>
</tr>
</thead>
<tbody v-for="section in sections" :key="section.name">
<tr>
<th :colspan="plans.length + 1" scope="colgroup" class="pt-10 pb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ section.name }}
</th>
</tr>
<tr v-for="feature in section.features" :key="feature.name" class="group">
<th scope="row" class="border-t border-gray-100 py-3.5 pr-4 font-normal text-gray-700 group-hover:bg-gray-50 dark:border-gray-800/70 dark:text-gray-300 dark:group-hover:bg-white/[0.02]">
{{ feature.name }}
<span v-if="feature.hint" class="mt-0.5 block text-xs text-gray-500 dark:text-gray-500">{{ feature.hint }}</span>
</th>
<td
v-for="plan in plans"
:key="plan.id"
class="border-t border-gray-100 px-4 py-3.5 group-hover:bg-gray-50 dark:border-gray-800/70 dark:group-hover:bg-white/[0.02]"
:class="plan.featured ? 'bg-sky-50/60 dark:bg-sky-400/[0.04]' : ''"
>
<template v-if="valueFor(feature, plan.id) === true">
<svg viewBox="0 0 20 20" aria-hidden="true" class="size-5 fill-sky-600 dark:fill-sky-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>
<span class="sr-only">Included in {{ plan.name }}</span>
</template>
<template v-else-if="valueFor(feature, plan.id) === false">
<span aria-hidden="true" class="text-gray-300 dark:text-gray-600">—</span>
<span class="sr-only">Not included in {{ plan.name }}</span>
</template>
<span v-else class="text-gray-900 dark:text-white">{{ valueFor(feature, plan.id) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| plans | ComparisonPlan[] | Hobby, Studio, Agency, Enterprise | Columns. Each has id, name, display price, optional period, description, cta label and featured flag. |
| sections | ComparisonSection[] | Publishing, Collaboration, Security and support | Feature groups. Each feature has a name, optional hint and a values map keyed by plan id (true, false or a string). |
| heading | string | 'Compare every plan' | Section heading. |
| subheading | string | 'All plans include unlimited viewers…' | Supporting text under the heading. |
| stickyTop | string | '0px' | CSS top offset for the sticky header row on desktop. |
| Event | Payload | Description |
|---|---|---|
| select | (plan: ComparisonPlan) | Fired when a plan's button is clicked. |
Component · Pricing tables
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.
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.