Price

An amount of money, formatted for its currency and locale by Intl.NumberFormat, with an optional compare-at price for a sale and a unit such as "/ month".

Commerce Price.jsx Price.d.ts Price.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Price is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Price } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

An amount of money, formatted for its currency and locale by Intl.NumberFormat, with an optional compare-at price for a sale and a unit such as "/ month".

Use it when

  • Showing what something costs: a product card, a cart line, a menu item, a plan, an order total.
  • A sale needs the original price beside the new one (compareAt).
  • The price is per something: unit="/ month", "each", "/ kg".

Don't use it when

  • The number is not money. Use Text with numeric for counts and measurements.
  • You need a range ("$20–$40") or "from $20". Compose two Prices, or put "From" in Text before one.

Example

<Price amount={24.5} compareAt={30} />
<Price amount={12} unit="/ month" size="lg" />
<Price amount={1290} currency="EUR" locale="de-DE" />
<Price amount={1200} currency="JPY" locale="ja-JP" />
<Price amount={0} freeLabel="Free delivery" size="sm" />

amount is in major units. Prices stored in minor units must be converted first: cents / 100 for USD, EUR or GBP; a zero-decimal currency such as JPY needs no conversion.

Variants

PropWhat it is for
size="sm"Cart lines, menu rows, dense lists. Label type at the small size.
size="md"Default. Product cards and summaries.
size="lg"The price on a product page or a plan card, at heading size.
compareAtA sale: when greater than amount, it shows struck through after the amount, and the amount turns the sale colour. Equal or lower is ignored.
unitWhat the price is per, after it, smaller and secondary.
freeLabelReplaces a formatted zero ("$0.00"), "Free" by default.

The locale defaults to the runtime's. Pass locale when server rendering so the server and the browser print the same string and hydration matches. An unknown currency code falls back to the number followed by the code.

Composition

Inline: it renders a <span>, so it sits in a line of text, a card's footer or a table cell. It wraps between the amount, the compare-at price and the unit when space is short, never inside one of them. Other commerce components (product cards, cart lines, menus) render their prices with it.

Tokens

Tier 3, in tokens/component/commerce.css, each repeated under .dark:

  • --dt-price-color (--dt-text-primary): the amount.
  • --dt-price-sale-color (--dt-text-danger): the amount when it is on sale.
  • --dt-price-compare-color (--dt-text-tertiary): the struck compare-at price.
  • --dt-price-unit-color (--dt-text-secondary): the unit.

Type comes from the semantic roles: --dt-text-label-md-*, --dt-text-label-lg-* or --dt-text-heading-md-* for the amount, and --dt-text-body-xs-* or --dt-text-body-sm-* for the compare-at price and the unit. Figures are tabular, so prices in a column line up.

Accessibility

  • On a sale, the visible amount and the struck price are hidden from assistive technology and a visually hidden span reads "Was $30.00, now $24.50", using the same formatted strings the page shows. A strikethrough alone is not announced by most screen readers, so without this the two prices would read as one confusing number.
  • The sale colour is never the only signal: the struck compare-at price carries it too.
  • The unit is ordinary text and is read after the price.

Content

  • unit is lowercase and short: "/ month", "each", "/ kg", "per person". Put a space after the slash.
  • freeLabel is sentence case: "Free", "Free delivery", "Included".
  • Don't add the currency code yourself; Intl places the symbol where the locale expects it (12,90 € in German, €12.90 in Irish English).

Props

The typed contract, from Price.d.ts.

import * as React from "react";

/** An amount of money, formatted for its currency and locale, with an optional compare-at price and unit. */
export interface PriceProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
  /**
   * The amount in major units: 24.5 for $24.50, 1200 for ¥1,200. Prices stored in
   * minor units (cents) must be converted first: divide by 100 for USD or EUR, and
   * leave a zero-decimal currency such as JPY as it is.
   */
  amount: number;
  /** ISO 4217 currency code, passed to Intl.NumberFormat. @default "USD" */
  currency?: string;
  /**
   * BCP 47 locale for the format, e.g. "de-DE" or "ja-JP". Defaults to the runtime's
   * locale; set it when server rendering so the server and the browser print the same.
   */
  locale?: string;
  /**
   * The original price, in the same units as amount. When it is greater than amount it
   * shows struck through after the amount, and the amount takes the sale colour.
   */
  compareAt?: number;
  /** What the price is per, shown after it, smaller and secondary: "/ month", "each", "/ kg". */
  unit?: string;
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  /** Shown instead of a formatted zero when amount is 0. @default "Free" */
  freeLabel?: string;
}

export declare function Price(props: PriceProps): React.JSX.Element;

Tokens it reads

Every custom property Price.jsx resolves, read from the source: 4 component, 151 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-price-colorcomponentvar(--dt-text-primary)
--dt-price-compare-colorcomponentvar(--dt-text-tertiary)
--dt-price-sale-colorcomponentvar(--dt-text-danger)
--dt-price-unit-colorcomponentvar(--dt-text-secondary)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";
import { VisuallyHidden } from "../primitives/VisuallyHidden.jsx";

/* Each size is a type role for the amount and a smaller one for the struck
   compare-at price and the unit beside it. */
const SIZES = {
  sm: { amount: "label-md", aside: "body-xs" },
  md: { amount: "label-lg", aside: "body-sm" },
  lg: { amount: "heading-md", aside: "body-sm" },
};

const role = (name) => ({
  fontFamily: `var(--dt-text-${name}-family)`,
  fontSize: `var(--dt-text-${name}-size)`,
  lineHeight: `var(--dt-text-${name}-line)`,
  fontWeight: `var(--dt-text-${name}-weight)`,
  letterSpacing: `var(--dt-text-${name}-tracking)`,
});

/* One formatter per currency and locale. An unknown currency code makes
   Intl throw; the amount still shows, with the code after it. */
function useFormat(currency, locale) {
  return React.useMemo(() => {
    try {
      const nf = new Intl.NumberFormat(locale, { style: "currency", currency });
      return (n) => nf.format(n);
    } catch (err) {
      return (n) => `${n} ${currency}`;
    }
  }, [currency, locale]);
}

export function Price({
  amount,
  currency = "USD",
  locale,
  compareAt,
  unit,
  size = "md",
  freeLabel = "Free",
  style,
  ...rest
}) {
  const format = useFormat(currency, locale);
  const s = SIZES[size] || SIZES.md;
  const shown = amount === 0 ? freeLabel : format(amount);
  const onSale = typeof compareAt === "number" && compareAt > amount;
  const was = onSale ? format(compareAt) : null;

  return (
    <span
      style={{
        position: "relative",
        display: "inline-flex", alignItems: "baseline", flexWrap: "wrap",
        columnGap: "var(--dt-space-inline-xs)",
        fontVariantNumeric: "tabular-nums",
        ...style,
      }}
      {...rest}
    >
      {onSale && <VisuallyHidden>{`Was ${was}, now ${shown}`}</VisuallyHidden>}
      <span
        aria-hidden={onSale || undefined}
        style={{ ...role(s.amount), color: onSale ? "var(--dt-price-sale-color)" : "var(--dt-price-color)", whiteSpace: "nowrap" }}
      >
        {shown}
      </span>
      {onSale && (
        <s aria-hidden="true" style={{ ...role(s.aside), color: "var(--dt-price-compare-color)", whiteSpace: "nowrap" }}>
          {was}
        </s>
      )}
      {unit && (
        <span style={{ ...role(s.aside), color: "var(--dt-price-unit-color)", whiteSpace: "nowrap" }}>{unit}</span>
      )}
    </span>
  );
}