Price

An amount written as money, with the old price struck through and the saving when it is reduced.

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 price
pnpm dlx ermineui add price
yarn dlx ermineui add price
bunx ermineui add price

That copies 3 files into src/components/ui/price/. Files you already have are kept.

Use

import { Price, formatMoney, percentOff } from '@/components/ui/price'
<Price :amount="96" :compare-at="120" show-saving />   <!-- $96.00, $120.00 struck through, "Save 20%" -->
<Price :amount="9" unit="/ month" currency="EUR" locale="de-DE" />

<SwatchGroup v-model="colour" label="Colour">
  <Swatch value="sand" color="#c9b79c" label="Sand" />
  <Swatch value="chalk" color="#f5f3ee" label="Chalk" unavailable />
</SwatchGroup>
<SwatchGroup v-model="size" label="Size">
  <Swatch value="s">S</Swatch>
  <Swatch value="m">M</Swatch>
</SwatchGroup>

<ProductCard title="Wool throw" href="/products/wool-throw" image="/img/throw.jpg" brand="Fell & Co" :price="96" :compare-at="120" :rating="4.8" :reviews="64" badge="Sale">
  <template #action><Button variant="secondary" size="icon" aria-label="Save for later"><Icon name="heart" /></Button></template>
  <Button variant="outline" size="sm" @click="add">Add to cart</Button>
</ProductCard>

<ProductGallery v-model="picture" :images="[{ src: '/img/mug-1.jpg', alt: 'Front of the mug' }, …]" thumbnails="left" zoom />

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
Price.vuesrc/components/ui/price/Price.vue
index.tssrc/components/ui/price/index.ts
money.tssrc/components/ui/price/money.ts

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.

Price.vue 90 lines
<script setup lang="ts">
import { computed } from 'vue'
import { formatMoney, percentOff } from './money'

const props = withDefaults(
  defineProps<{
    /** What it costs now, in whole units of the currency: 24.5 for $24.50. */
    amount: number
    /** What it cost before. When higher than the amount, it is shown struck through beside it. */
    compareAt?: number
    /** Three-letter currency code. */
    currency?: string
    locale?: string
    /** What the price is for, e.g. "/ month" or "each". */
    unit?: string
    /** Add "Save 20%" after a reduced price. */
    showSaving?: boolean
    size?: 'sm' | 'md' | 'lg'
  }>(),
  { currency: 'USD', locale: 'en-US', size: 'md' },
)

const saving = computed(() => percentOff(props.amount, props.compareAt))
</script>

<template>
  <span class="er-price" :data-size="size" :data-sale="saving ? '' : undefined">
    <!-- "Now" and "Was" are read aloud only: a struck-through number says nothing to a screen reader. -->
    <span class="er-price-now"><span v-if="saving" class="er-price-hidden">Now </span>{{ formatMoney(amount, currency, locale) }}</span>
    <span v-if="unit" class="er-price-unit">{{ unit }}</span>
    <s v-if="saving && compareAt !== undefined" class="er-price-was"><span class="er-price-hidden">Was </span>{{ formatMoney(compareAt, currency, locale) }}</s>
    <span v-if="saving && showSaving" class="er-price-saving">Save {{ saving }}%</span>
  </span>
</template>

<style>
@layer components {
  .er-price {
    display: inline-flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0 0.375rem;
    font-size: 0.875rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.25rem;
  }

  .er-price[data-size='sm'] {
    font-size: 0.8125rem;
  }

  .er-price[data-size='lg'] {
    font-size: 1.5rem;
    line-height: 2rem;
  }

  .er-price-now {
    font-weight: var(--er-weight-semibold);
  }

  .er-price[data-sale] .er-price-now {
    color: var(--er-destructive);
  }

  .er-price-unit,
  .er-price-was {
    color: var(--er-muted-foreground);
    font-size: max(0.75em, 0.8125rem);
  }

  .er-price-saving {
    padding: 0 0.375rem;
    border-radius: var(--er-radius-sm);
    background: var(--er-destructive);
    color: var(--er-destructive-foreground);
    font-size: 0.75rem;
    font-weight: var(--er-weight-medium);
  }

  .er-price-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
</style>
index.ts 4 lines
/** Price — an amount written as money, with the old price struck through and the saving when it is reduced. */
export { default as Price } from './Price.vue'
export { formatMoney, percentOff } from './money'
money.ts 19 lines
const formatters = new Map<string, Intl.NumberFormat>()

/**
 * An amount written as money: formatMoney(24.5) gives "$24.50". Amounts are in whole units of the
 * currency (dollars, not cents). The locale is fixed unless you pass one, so server and browser write the same text.
 */
export function formatMoney(amount: number, currency = 'USD', locale = 'en-US'): string {
  const key = `${locale}/${currency}`
  let formatter = formatters.get(key)
  if (!formatter) formatters.set(key, (formatter = new Intl.NumberFormat(locale, { style: 'currency', currency })))
  return formatter.format(amount)
}

/** How much cheaper `amount` is than `compareAt`, as a whole percentage. 0 when it is not cheaper. */
export function percentOff(amount: number, compareAt: number | undefined): number {
  if (compareAt === undefined || compareAt <= 0 || amount >= compareAt) return 0
  return Math.round((1 - amount / compareAt) * 100)
}