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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add pricepnpm dlx ermineui add priceyarn dlx ermineui add pricebunx ermineui add priceThat 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
| File | Where it goes |
|---|---|
Price.vue | src/components/ui/price/Price.vue |
index.ts | src/components/ui/price/index.ts |
money.ts | src/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)
}