Commerce
Price
Shows a price in any currency and locale, with an optional struck earlier price, saving, unit and period.
- Built on
Intl.NumberFormatformatToParts<data><s>- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.0 kB
- Dependencies
- React
Was $40, now $29 per seat per month, Save 28%
Try its options
Loading its options.
Code
import { Price } from "@/components/equip/price";
export default function Demo() {
return <Price amount={29} compareAt={40} per="seat" period="month" showSaving="percent" />;
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<span class="eq-price" data-size="m">
<span class="eq-price-was"><span class="eq-price-hidden">Was </span><s>$40</s><span class="eq-price-hidden">, now </span></span>
<data class="eq-price-now" value="29"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">$</span><span class="eq-price-whole">29</span></span><span class="eq-price-hidden">$29</span></data>
<span class="eq-price-unit"><span aria-hidden="true">/seat /month</span><span class="eq-price-hidden"> per seat per month</span></span>
<span class="eq-price-saving"><span class="eq-price-hidden">, </span>Save 28%</span>
</span>Examples
Sizes
At size l the currency symbol and the cents are set small and high, the way a price tag reads.
$12.50 per user per month$12.50 per user per month$12.50 per user per month
Code
import { Price } from "@/components/equip/price";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, justifyItems: "start" }}>
<Price size="s" amount={12.5} per="user" period="month" />
<Price size="m" amount={12.5} per="user" period="month" />
<Price size="l" amount={12.5} per="user" period="month" />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:16px;justify-items:start">
<span class="eq-price" data-size="s">
<data class="eq-price-now" value="12.5"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">$</span><span class="eq-price-whole">12</span><span class="eq-price-fraction">.50</span></span><span class="eq-price-hidden">$12.50</span></data>
<span class="eq-price-unit"><span aria-hidden="true">/user /month</span><span class="eq-price-hidden"> per user per month</span></span>
</span>
<span class="eq-price" data-size="m">
<data class="eq-price-now" value="12.5"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">$</span><span class="eq-price-whole">12</span><span class="eq-price-fraction">.50</span></span><span class="eq-price-hidden">$12.50</span></data>
<span class="eq-price-unit"><span aria-hidden="true">/user /month</span><span class="eq-price-hidden"> per user per month</span></span>
</span>
<span class="eq-price" data-size="l">
<data class="eq-price-now" value="12.5"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">$</span><span class="eq-price-whole">12</span><span class="eq-price-fraction">.50</span></span><span class="eq-price-hidden">$12.50</span></data>
<span class="eq-price-unit"><span aria-hidden="true">/user /month</span><span class="eq-price-hidden"> per user per month</span></span>
</span>
</div>Currencies and locales
Intl.NumberFormat places the symbol, separators and decimals for each locale; yen has no minor unit.
1.249,99 €¥18,900 per yearWas ₹1,199, now ₹899, Save ₹300
Code
import { Price } from "@/components/equip/price";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
<Price amount={1249.99} currency="EUR" locale="de-DE" />
<Price amount={18900} currency="JPY" locale="ja-JP" period="year" />
<Price amount={899} currency="INR" locale="en-IN" compareAt={1199} showSaving="amount" />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;justify-items:start">
<span class="eq-price" data-size="m">
<data class="eq-price-now" value="1249.99"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-whole">1.249</span><span class="eq-price-fraction">,99</span><span class="eq-price-whole"> </span><span class="eq-price-currency">€</span></span><span class="eq-price-hidden">1.249,99 €</span></data>
</span>
<span class="eq-price" data-size="m">
<data class="eq-price-now" value="18900"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">¥</span><span class="eq-price-whole">18,900</span></span><span class="eq-price-hidden">¥18,900</span></data>
<span class="eq-price-unit"><span aria-hidden="true">/year</span><span class="eq-price-hidden"> per year</span></span>
</span>
<span class="eq-price" data-size="m">
<span class="eq-price-was"><span class="eq-price-hidden">Was </span><s>₹1,199</s><span class="eq-price-hidden">, now </span></span>
<data class="eq-price-now" value="899"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">₹</span><span class="eq-price-whole">899</span></span><span class="eq-price-hidden">₹899</span></data>
<span class="eq-price-saving"><span class="eq-price-hidden">, </span>Save ₹300</span>
</span>
</div>Starting price
From $4 per GB per month
Code
import { Price } from "@/components/equip/price";
export default function Example() {
return <Price from amount={4} per="GB" period="month" />;
}HTMLworks in any framework, with the part's CSS
<span class="eq-price" data-size="m">
<span class="eq-price-from">From </span>
<data class="eq-price-now" value="4"><span class="eq-price-figure" aria-hidden="true"><span class="eq-price-currency">$</span><span class="eq-price-whole">4</span></span><span class="eq-price-hidden">$4</span></data>
<span class="eq-price-unit"><span aria-hidden="true">/GB /month</span><span class="eq-price-hidden"> per GB per month</span></span>
</span>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
amount | number | none | The current price, in major units (29 means $29). |
currency | string | "USD" | ISO 4217 currency code. |
locale | string | "en-US" | Sets the symbol position, separators and grouping. |
compareAt | number | none | The earlier price, struck through and read as "Was". Ignored unless it is higher than amount. |
period | string | none | Billing period, such as "month", shown as /month and read as "per month". |
per | string | none | Unit the price covers, such as "seat", shown before the period. |
from | boolean | false | Prefixes "From" for a starting price. |
size | "s" | "m" | "l" | "m" | Figure size. At l the symbol and cents are set small and high. |
showSaving | "percent" | "amount" | none | Adds a saving label against compareAt, as a rounded percentage or as money. |
...rest | ComponentProps<"span"> | none | Every native span attribute. |
Anatomy
| root | .eq-price | Size is on data-size. |
|---|---|---|
| from | .eq-price-from | |
| was | .eq-price-was | Wraps the struck <s> price and its hidden "Was". |
| now | .eq-price-now | A <data> element whose value is the raw amount. |
| figure | .eq-price-figure | The visible price, hidden from assistive tech in favour of one whole string. |
| currency | .eq-price-currency | |
| whole | .eq-price-whole | Integer digits and group separators. |
| fraction | .eq-price-fraction | Decimal separator and minor units. |
| unit | .eq-price-unit | Per unit and period. |
| saving | .eq-price-saving | |
| hidden text | .eq-price-hidden | Words for screen readers only. |
Notes
- Renders on the server with no client JavaScript.
- Reads as one sentence, such as "Was $40, now $29 per seat per month, save 28%", because the spoken order is fixed in the DOM and only the visual order changes.
- Whole amounts drop their zero cents ($29, not $29.00); amounts with cents keep them.
- Formatting runs where it renders, so pass an explicit locale to keep server and browser output identical.
- Pass the amount in major units as a number; for exact money arithmetic keep minor units in your own code and divide only for display.
- Numerals are tabular, so prices in a column line up.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/price.tsx105 lines
import type { ComponentProps } from "react";
import "./price.css";
export type PriceProps = ComponentProps<"span"> & {
amount: number;
/** ISO 4217 code. */
currency?: string;
locale?: string;
/** The earlier price, shown struck through. Ignored unless it is higher than amount. */
compareAt?: number;
/** Billing period, such as "month". */
period?: string;
/** Unit the price is for, such as "seat". */
per?: string;
/** Prefixes "From", for a price that starts here. */
from?: boolean;
size?: "s" | "m" | "l";
/** Shows the saving against compareAt as a percentage or as money. */
showSaving?: "percent" | "amount";
};
type Piece = "currency" | "whole" | "fraction";
const pieceClass: Record<Piece, string> = {
currency: "eq-price-currency",
whole: "eq-price-whole",
fraction: "eq-price-fraction",
};
export function Price({
amount,
currency = "USD",
locale = "en-US",
compareAt,
period,
per,
from = false,
size = "m",
showSaving,
className,
...rest
}: PriceProps) {
const format = new Intl.NumberFormat(locale, {
style: "currency",
currency,
trailingZeroDisplay: "stripIfInteger",
});
/* Symbol, whole and fraction as runs, so each can be set on its own. */
const runs: { piece: Piece; text: string }[] = [];
for (const part of format.formatToParts(amount)) {
const piece: Piece =
part.type === "currency" ? "currency" : part.type === "decimal" || part.type === "fraction" ? "fraction" : "whole";
const last = runs[runs.length - 1];
if (last && last.piece === piece) last.text += part.value;
else runs.push({ piece, text: part.value });
}
const was = compareAt != null && compareAt > amount ? compareAt : undefined;
const saving =
was === undefined || !showSaving
? undefined
: showSaving === "percent"
? `${Math.round(((was - amount) / was) * 100)}%`
: format.format(was - amount);
const unit = [per, period].filter(Boolean);
return (
<span
{...rest}
className={["eq-price", className].filter(Boolean).join(" ")}
data-size={size}
>
{from && <span className="eq-price-from">From </span>}
{was !== undefined && (
<span className="eq-price-was">
<span className="eq-price-hidden">Was </span>
<s>{format.format(was)}</s>
<span className="eq-price-hidden">, now </span>
</span>
)}
<data className="eq-price-now" value={amount}>
<span className="eq-price-figure" aria-hidden="true">
{runs.map((run, i) => (
<span key={i} className={pieceClass[run.piece]}>
{run.text}
</span>
))}
</span>
<span className="eq-price-hidden">{format.format(amount)}</span>
</data>
{unit.length > 0 && (
<span className="eq-price-unit">
<span aria-hidden="true">{unit.map((u) => `/${u}`).join(" ")}</span>
<span className="eq-price-hidden"> {unit.map((u) => `per ${u}`).join(" ")}</span>
</span>
)}
{saving !== undefined && (
<span className="eq-price-saving">
<span className="eq-price-hidden">, </span>Save {saving}
</span>
)}
</span>
);
}components/equip/price.css104 lines
@layer equip {
.eq-price {
--_figure: calc(var(--eq-text-l) * 1.5);
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--eq-space-2);
row-gap: var(--eq-space-1);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.2 var(--eq-font);
font-variant-numeric: tabular-nums;
}
.eq-price[data-size="s"] {
--_figure: var(--eq-text-m);
column-gap: var(--eq-space-1);
}
.eq-price[data-size="l"] {
--_figure: calc(var(--eq-text-l) * 2.25);
}
/* Spoken order is from, was, now, unit, saving; the eye reads the current price first. */
.eq-price-from {
order: 0;
color: var(--eq-ink-2);
}
.eq-price-now {
order: 1;
}
.eq-price-unit {
order: 2;
color: var(--eq-ink-2);
}
.eq-price-was {
order: 3;
color: var(--eq-ink-3);
}
.eq-price-was s {
text-decoration-thickness: 1px;
}
.eq-price-saving {
order: 4;
display: inline-flex;
align-items: center;
align-self: center;
height: 20px;
padding-inline: var(--eq-space-1);
border-radius: var(--eq-radius-s);
background: color-mix(in srgb, var(--eq-ok) 14%, transparent);
color: var(--eq-ok);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
white-space: nowrap;
}
.eq-price-figure {
display: inline-flex;
align-items: baseline;
font-size: var(--_figure);
font-weight: var(--eq-weight-strong);
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
}
.eq-price[data-size="s"] .eq-price-figure {
letter-spacing: 0;
}
/* At the largest size the symbol and cents ride high and small, as on a price tag. */
.eq-price[data-size="l"] :is(.eq-price-currency, .eq-price-fraction) {
align-self: flex-start;
margin-top: 0.12em;
font-size: 0.5em;
letter-spacing: 0;
}
.eq-price[data-size="l"] .eq-price-currency {
margin-inline-end: 0.08em;
}
.eq-price-whole {
font-variant-numeric: tabular-nums;
}
.eq-price-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}