Price

Free

Locale-aware money formatting with an optional compare-at price and an auto-calculated discount, shown as a badge or inline text. Minor units are scaled by the currency rather than by a hardcoded 100, the rounding rule is stated rather than implied, and ranges, credits and a stated tax basis are first-class.

</>sv-price pricemoneycurrencydiscountecommercetaxvatrounding

Complexity tier: Primitive Presents a value: held to semantic markup and contrast, with its right-to-left and print gaps recorded and the right-to-left debt allowed only to shrink.

import { SvPriceComponent } from '@styloviz/commerce';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Minor units + currency

Store prices in cents and format for any locale.

example.htmlAngular
<sv-price [amount]="4999" [minorUnits]="true" currency="GBP" locale="en-GB" />

Free label

example.htmlAngular
<sv-price [amount]="0" freeLabel="Free" />

Minor units are not always hundredths

All three of these hold the integer `1000`. The currency decides what it is worth: USD has two decimals, JPY none, KWD three. The scale is read back from `Intl` for the currency in play, so `1000` prints as $10.00, ¥1,000 and 1.000 KWD — dividing by 100 regardless would show a tenth of a thousand yen and ten dinars instead of one.

example.htmlAngular
<sv-price [amount]="1000" [minorUnits]="true" currency="USD" locale="en-US" />
<sv-price [amount]="1000" [minorUnits]="true" currency="JPY" locale="ja-JP" />
<sv-price [amount]="1000" [minorUnits]="true" currency="KWD" locale="en-US" />

Rounding you can audit

`roundingMode` defaults to `halfExpand`, which is also what `Intl` applies when nothing is said — so setting nothing changes no output. It exists because a rule chosen implicitly is a rule nobody can reconcile against: two systems that agree on the amount and disagree on the mode disagree by a cent.

example.htmlAngular
<!-- bankers' rounding, for a figure that has to match the ledger -->
<sv-price [amount]="1.005" roundingMode="halfEven" />

<!-- never quote below the real price -->
<sv-price [amount]="1.001" roundingMode="ceil" />

A range, and an open-ended one

A product whose variants are not all one price does not have a price. `amountTo` prints both ends and reads the dash as the word "to"; `from` prints a lower bound alone. An `amountTo` at or below `amount` is dropped rather than printed backwards, and a range suppresses `compareAt` — a percentage off a span is not one number.

example.htmlAngular
<sv-price [amount]="49.99" [amountTo]="79.99" />
<sv-price [amount]="49.99" [from]="true" fromLabel="From" />

Money owed the other way

A negative amount is a refund or a credit, and the minus glyph in front of it is not read out — so the figure is announced as a credit. A figure that rounds away to nothing loses the sign, because "-$0.00" reads as a refund that is not happening. Under `floor`, -0.001 legitimately is -$0.01 and keeps it.

example.htmlAngular
<sv-price [amount]="-12.5" />

Stating the tax basis

A consumer-facing price with no stated basis is not compliant in the EU, the UK or Australia. The component cannot know the jurisdiction or the buyer, so `unstated` is the default and stating it is one input; the note prints as text under the price, never as a colour or a tooltip. The rate goes in the label.

example.htmlAngular
<sv-price [amount]="49.99" taxMode="inclusive" taxLabel="20% VAT" />
<sv-price [amount]="49.99" taxMode="exclusive" taxLabel="sales tax" />

When to use

  • Rendering any product / cart / order price consistently across the app.
  • You want a strikethrough original price with an automatic saving badge.
  • Prices are stored in minor units and need locale-aware formatting.
  • A product is priced by variant or configuration and needs a range or a "from".
  • The figure is a refund, credit or adjustment rather than something owed to you.
  • You sell where the tax basis has to be printed beside the price.

Async states

None — this component has nothing to load, so it has no loading, empty or error state to drive.

Inputs

NameTypeDefaultDescription
amountrequirednumber—Current price. Interpreted as minor units (cents) when `minorUnits` is true.
compareAtnumber | nullnullOriginal / "compare-at" price for a discount. `null` hides it.
currencystring'USD'ISO 4217 currency code.
localestring'en-US'BCP-47 locale for formatting.
minorUnitsbooleanfalseTreat `amount` / `compareAt` / `amountTo` as minor units. MINOR UNITS ARE NOT ALWAYS HUNDREDTHS. This used to divide by 100 no matter the currency, which put the decimal point two places out for every currency that does not have two decimals: `1000` in JPY became ¥10 instead of ¥1,000, and `1000` in KWD became 10.000 KWD instead of 1.000. The scale is now read back from `Intl` for the currency in play.
sizePriceSize'md'Text size.
alignPriceAlign'start'Horizontal alignment of the price block.
discountPriceDiscount'badge'How to present the saving when `compareAt` is set.
freeLabelstring''Label shown when the amount is 0 (e.g. "Free"). Empty renders a formatted 0.
fractionDigitsnumber | nullnullDecimal places, or `null` for the currency's own convention. `null` IS THE DEFAULT AND IT IS THE RIGHT ONE FOR A SHOP. USD wants two places, JPY wants none, and `Intl` already knows which — hard-coding a number would print `¥1,200.00`, a figure that does not exist. It exists because a whole class of prices are never quoted to the cent: a house at `$1,180,000.00` reads as a rounding error, and every property portal, car listing and B2B quote drops the decimals. Setting `0` is the caller saying "this is that kind of price", which is a judgement the component cannot make from the amount alone. It changes only how the figure is *printed*. It says nothing about how the host *stored* it, so it never moves the `minorUnits` scale: a house price held in cents and shown without decimals is still cents.
roundingModePriceRoundingMode'halfExpand'How a figure with more precision than the printed decimals is resolved. A ROUNDING RULE CHOSEN IMPLICITLY IS A ROUNDING RULE NOBODY CAN AUDIT. Two systems that agree on the amount and disagree on the mode disagree by a cent, which is a reconciliation task rather than a display bug. The default is `halfExpand` — the same rule `Intl` applies when nothing is said — so this input changes no existing output; it only makes the rule visible and overridable. Finance systems that use bankers' rounding want `halfEven`; a shop that must never quote below the real price wants `ceil`.
amountTonumber | nullnullUpper bound of a price range — "$49.99 – $79.99" for a product whose variants are not all one price. Ignored unless it is above `amount`, on the same rule as `compareAt`: a range that runs backwards is bad data, and printing it as given would tell a shopper the cheapest option costs more than the dearest. A range suppresses `compareAt` and the saving, because a percentage off a span is not one number.
frombooleanfalsePrint `amount` as the start of an open-ended range — "From $49.99" — for a product priced by configuration. Ignored when `amountTo` is set, since a closed range already says it.
fromLabelstring'From'Wording for the open-ended range.
taxModePriceTaxMode'unstated'Whether the printed figure includes tax. `unstated` IS THE DEFAULT ONLY BECAUSE THIS COMPONENT CANNOT KNOW. Whether a price must be shown with tax included is a property of the jurisdiction and the buyer, not of the number — and in the EU, the UK and Australia a consumer-facing price with no stated basis is not compliant. There is deliberately no way to state a basis *wrongly* cheaply: pick `inclusive` or `exclusive` and the component prints it beneath the price, as text, every time.
taxLabelstring'tax'What the tax is called — 'VAT', 'GST', 'sales tax'. Also the place to put a rate: `'20% VAT'` prints "incl. 20% VAT".

Customization & theming

  • Four sizes (sm/md/lg/xl) and three alignments — alignment drives both axes, so the tax note lines up with the price.
  • Discount as a badge, inline text, or hidden.
  • Any ISO 4217 currency + BCP-47 locale; a code `Intl` rejects falls back to "CODE 49.99" instead of throwing and blanking the grid.
  • Nine rounding modes, named exactly as `Intl` names them.
  • Optional Free label for zero amounts (ignored on a range — "Free – $79.99" is a lie about every variant but one).
  • Range wording via `fromLabel`; tax wording and rate via `taxLabel`.

Best practices

  • Do: Store amounts in minor units and let `sv-price` format them with `minorUnits`.
  • Do: Pass `compareAt` only when it is genuinely higher than `amount`.
  • Do: Set `roundingMode` explicitly when the figure has to agree with a ledger or an invoice. The default matches `Intl`, so this only ever makes the existing rule visible — it does not change it.
  • Do: Set `taxMode` on every consumer-facing price in a jurisdiction that requires a basis, and put the rate in `taxLabel` (`'20% VAT'`) so the shopper can check the arithmetic.
  • Do: Use `amountTo` for a variant range and `from` for a configurable product, rather than printing the cheapest variant as if it were the price.
  • Don't: Don't pre-format the string yourself — bind the raw number so currency/locale stay consistent.
  • Don't: Don't assume `minorUnits` means hundredths and pre-divide by 100 on the way in. JPY has no minor unit and KWD has three decimals; hand over the stored integer and let the currency set the scale.
  • Don't: Don't use `fractionDigits` to say how the amount is stored. It changes only how the figure prints — a house price held in cents and shown with no decimals is still cents.
  • Don't: Don't pass a `compareAt` against a negative amount expecting a saving. -50 against 100 is not "150% off"; a credit has no discount and the component drops it.

Accessibility

  • Prices are real text (tabular-nums), not images.
  • The original price uses a semantic `<s>` element.
  • Sufficient contrast for the sale and discount colours in light and dark mode.
  • A sale price is prefixed "Sale price" and the struck figure "Regular price", because a strike-through conveys nothing to a screen reader — without them the pair reads as two numbers with no way to tell which is charged.
  • A range reads "$49.99 to $79.99": the en dash is `aria-hidden` and the word carries it, since some screen readers read the glyph as nothing and others as "dash".
  • A negative amount is prefixed "Credit", because the minus sign in front of a figure is not announced.
  • The discount badge is a `role="img"` with an `aria-label` of "N% off", so the U+2212 glyph is replaced by words.
  • The tax basis is text in the accessibility tree, not a title attribute — so it is read, translated and findable.

Feedback on Price

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.