Pricing table

Plans side by side with what each costs and includes, a switch between monthly and yearly billing, and one plan marked out.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add pricing-table
pnpm dlx ermineui add pricing-table
yarn dlx ermineui add pricing-table
bunx ermineui add pricing-table

That copies 3 files into src/components/ui/pricing-table/, and the 4 components it is built from. Files you already have are kept.

Use

import { PricingTable } from '@/components/ui/pricing-table'
<ComparisonTable caption="Mugs compared" :columns="[{ key: 'mug', title: 'Mug' }, { key: 'travel', title: 'Travel mug', highlight: true }]" :rows="[{ label: 'Lid included', values: { mug: false, travel: true } }]" />

<PricingTable v-model:billing="billing" :plans="plans" current="hobby" @select="(plan, billing) => choose(plan, billing)" />

<SizeGuide v-model:unit="unit" :measures="[{ key: 'chest', label: 'Chest' }]" :sizes="[{ size: 'M', measurements: { chest: [96, 101] } }]" selected="M" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
PricingTable.vuesrc/components/ui/pricing-table/PricingTable.vue
index.tssrc/components/ui/pricing-table/index.ts
types.tssrc/components/ui/pricing-table/types.ts

It is built from: Badge, Button, Price, Segmented control. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

PricingTable.vue 214 lines
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '../badge'
import { Button } from '../button'
import { formatMoney, percentOff } from '../price'
import { SegmentedControl, SegmentedControlItem } from '../segmented-control'
import type { PricingBilling, PricingPlan } from './types'

const props = withDefaults(
  defineProps<{
    plans: readonly PricingPlan[]
    currency?: string
    locale?: string
    /** Heading level of each plan's name. */
    titleAs?: string
    /** The id of the plan the reader is on now. Its button says so and cannot be pressed. */
    current?: string
    currentLabel?: string
  }>(),
  { currency: 'USD', locale: 'en-US', titleAs: 'h3', currentLabel: 'Current plan' },
)

const emit = defineEmits<{
  /** A plan's button was pressed. */
  select: [plan: PricingPlan, billing: PricingBilling]
}>()

/** How the reader wants to pay. The switch only appears when some plan has a yearly price. */
const billing = defineModel<PricingBilling>('billing', { default: 'monthly' })

const hasYearly = computed(() => props.plans.some((plan) => typeof plan.price === 'object'))
/** The largest saving from paying yearly, to tempt the reader with on the switch. */
const bestSaving = computed(() => Math.max(0, ...props.plans.map((plan) => (typeof plan.price === 'object' ? percentOff(plan.price.yearly, plan.price.monthly) : 0))))

function amount(plan: PricingPlan) {
  if (plan.price === undefined) return undefined
  return typeof plan.price === 'number' ? plan.price : plan.price[billing.value]
}

// Whole amounts are written without ".00", which reads better at this size.
const money = (value: number) => formatMoney(value, props.currency, props.locale).replace(/[.,]00(?=\D*$)/, '')
</script>

<template>
  <div class="er-pricing-table">
    <div v-if="hasYearly" class="er-pricing-table-billing">
      <SegmentedControl v-model="billing" label="Billing period">
        <SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
        <SegmentedControlItem value="yearly">Yearly<span v-if="bestSaving" class="er-pricing-table-saving">Save {{ bestSaving }}%</span></SegmentedControlItem>
      </SegmentedControl>
    </div>

    <ul class="er-pricing-table-plans">
      <li v-for="plan in plans" :key="plan.id" class="er-pricing-table-plan" :data-featured="plan.featured ? '' : undefined">
        <div class="er-pricing-table-head">
          <component :is="titleAs" class="er-pricing-table-name">{{ plan.name }}</component>
          <Badge v-if="plan.badge">{{ plan.badge }}</Badge>
        </div>
        <p v-if="plan.description" class="er-pricing-table-description">{{ plan.description }}</p>

        <p class="er-pricing-table-price">
          <template v-if="amount(plan) !== undefined">
            <strong>{{ money(amount(plan)!) }}</strong>
            <span>/ month<template v-if="typeof plan.price === 'object' && billing === 'yearly'">, billed yearly</template></span>
          </template>
          <strong v-else>{{ plan.priceLabel ?? 'Contact us' }}</strong>
        </p>

        <Button
          :as="plan.href && plan.id !== current ? 'a' : 'button'"
          :href="plan.id !== current ? plan.href : undefined"
          :variant="plan.featured ? 'default' : 'outline'"
          :disabled="plan.id === current"
          @click="!plan.href && emit('select', plan, billing)"
        >
          {{ plan.id === current ? currentLabel : (plan.cta ?? 'Choose') }}<span v-if="plan.id !== current" class="er-pricing-table-hidden"> {{ plan.name }}</span>
        </Button>

        <ul class="er-pricing-table-features" :aria-label="`${plan.name} includes`">
          <li v-for="feature in plan.features" :key="feature">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
            {{ feature }}
          </li>
        </ul>
      </li>
    </ul>
  </div>
</template>

<style>
@layer components {
  .er-pricing-table {
    display: grid;
    gap: 1.5rem;
    width: 100%;
    container-type: inline-size;
  }

  .er-pricing-table-billing {
    display: flex;
    justify-content: center;
  }

  .er-pricing-table-saving {
    padding: 0.125rem 0.375rem;
    border-radius: 999px;
    background: color-mix(in oklab, var(--er-success) 15%, transparent);
    color: var(--er-foreground);
    font-size: 0.6875rem;
    font-weight: var(--er-weight-semibold);
  }

  /* As many plans in a row as fit; the table measures itself, not the screen. */
  .er-pricing-table-plans {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
    gap: 1rem;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .er-pricing-table-plan {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    padding: 1.5rem;
    border: 1px solid var(--er-border);
    border-radius: var(--er-radius-xl);
    background: var(--er-card);
    color: var(--er-card-foreground);
  }

  .er-pricing-table-plan[data-featured] {
    border-color: var(--er-primary);
    box-shadow:
      0 0 0 1px var(--er-primary),
      var(--er-shadow-md);
  }

  .er-pricing-table-head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
  }

  .er-pricing-table-name {
    margin: 0;
    font-size: 1rem;
    font-weight: var(--er-weight-semibold);
    line-height: 1.5rem;
  }

  .er-pricing-table-description {
    margin: -0.5rem 0 0;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
    line-height: 1.25rem;
  }

  .er-pricing-table-price {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.125rem 0.375rem;
    margin: 0;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
  }

  .er-pricing-table-price strong {
    color: var(--er-foreground);
    font-size: 2rem;
    font-weight: var(--er-weight-semibold);
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.02em;
    line-height: 2.25rem;
  }

  .er-pricing-table-features {
    display: grid;
    gap: 0.5rem;
    margin: 0;
    padding: 0;
    font-size: 0.875rem;
    line-height: 1.25rem;
    list-style: none;
  }

  .er-pricing-table-features li {
    display: flex;
    gap: 0.5rem;
  }

  .er-pricing-table-features svg {
    flex-shrink: 0;
    width: 1rem;
    height: 1.25rem;
    color: var(--er-success);
  }

  .er-pricing-table-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
</style>
index.ts 4 lines
/** Pricing table — plans side by side with what each costs and includes, a switch between monthly and yearly billing, and one plan marked out. */
export { default as PricingTable } from './PricingTable.vue'
export type { PricingBilling, PricingPlan } from './types'
types.ts 26 lines
export interface PricingPlan {
  id: string
  name: string
  /** Who the plan is for, in a line. */
  description?: string
  /**
   * What it costs per month, in whole units of the currency. Give both figures to offer yearly billing:
   * `yearly` is the price of each month when a year is paid at once. Leave out for "Contact us".
   */
  price?: number | { monthly: number; yearly: number }
  /** Shown in place of a price when there is none. */
  priceLabel?: string
  /** What the plan includes, one line each. */
  features: readonly string[]
  /** The words on the button. */
  cta?: string
  /** Where the button goes. Without it the button emits `select`. */
  href?: string
  /** The plan being recommended: it is outlined and its button is filled. */
  featured?: boolean
  /** A short label beside the name, e.g. "Most popular". */
  badge?: string
}

export type PricingBilling = 'monthly' | 'yearly'