Skip to contentVibraUI
Metrics

Price

An amount in its currency, what it buys, and what it used to cost.

Both figures go through format's formatCurrency, so the symbol's place, the separators and the fraction digits are the locale's rather than a template's — pass locale and currency together and a European price reads as one. The digits are tabular, because a column of plans that does not line up reads as a mistake before it reads as a number. Two details are for the reader who is not looking: the period shows a slash and reads as a preposition, so "$29/month" is heard as "29 dollars per month"; and the compare-at price is a real <s> carrying an sr-only "was", so a struck-through figure is not simply read out as a second, cheaper price. The amount is on the numeral register and the terms beside it stay on the body register, so a plan card reads as one number with its terms rather than three competing figures.

Install

npx shadcn@latest add @vibra/price

Needs the @vibra registry in your components.json — set it up once.

Examples

Sizes and currencies

The three sizes, and the same component in another locale.

Props

PropTypeDefaultDescription
amountnumber—The figure, in major units — 29, not 2900.
currencystring"USD"ISO 4217 code.
periodstring—What the amount buys — "month", "seat", "1,000 events". Read out as "per month".
compareAtnumber—The price this one replaces; struck through and announced as "was".
size"sm" | "default" | "lg""default"18, 24 and 36px on the amount — a table row, a plan card, a pricing page.
localestring"en-US"BCP 47 tag deciding the symbol's place and the separators.

Dependencies

Source

components/ui/price.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"

const priceVariants = cva("inline-flex flex-wrap items-baseline gap-x-1.5", {
  variants: {
    size: {
      sm: "text-sm",
      default: "text-base",
      lg: "text-lg",
    },
  },
  defaultVariants: { size: "default" },
})

// The amount is the one thing on the numeral register; the period and the old
// price stay on the body register beside it, so a card of plans reads as one
// number with its terms rather than three competing figures.
const priceAmountVariants = cva("type-numeral tabular-nums", {
  variants: {
    size: {
      sm: "text-lg",
      default: "text-2xl",
      lg: "text-4xl",
    },
  },
  defaultVariants: { size: "default" },
})

export type PriceSize = NonNullable<VariantProps<typeof priceVariants>["size"]>

export type PriceProps = React.ComponentProps<"span"> & {
  amount: number
  /** ISO 4217 code the amount is in. */
  currency?: string
  /** What the amount buys — "month", "seat", "1,000 events". Read out as "per month". */
  period?: string
  /** The price this one replaces; struck through and announced as the old one. */
  compareAt?: number
  size?: PriceSize
  /** BCP 47 tag deciding the symbol's place and the separators. */
  locale?: string
}

/**
 * A price: the amount in its currency, what it buys, and what it used to cost.
 *
 * Both figures go through `formatCurrency`, so the symbol, the separators and
 * the fraction digits are the locale's rather than a template's — and the
 * digits are tabular, because a column of plans that does not line up reads as
 * a mistake before it reads as a number.
 */
function Price({
  className,
  amount,
  currency = "USD",
  period,
  compareAt,
  size = "default",
  locale,
  ...props
}: PriceProps) {
  const money = (value: number) => formatCurrency(value, currency, { locale })

  return (
    <span
      data-slot="price"
      data-size={size}
      className={cn(priceVariants({ size }), className)}
      {...props}
    >
      <span data-slot="price-amount" className={priceAmountVariants({ size })}>
        {money(amount)}
      </span>

      {period ? (
        <span data-slot="price-period" className="font-sans tracking-normal text-muted-foreground">
          {/* The slash is a typographic shorthand; the preposition is what a
              screen reader should hear, so each is given to one audience. */}
          <span aria-hidden="true">/</span>
          <span className="sr-only">per </span>
          {period}
        </span>
      ) : null}

      {compareAt !== undefined ? (
        <s
          data-slot="price-compare"
          className="font-sans tabular-nums tracking-normal text-muted-foreground"
        >
          <span className="sr-only">was </span>
          {money(compareAt)}
        </s>
      ) : null}
    </span>
  )
}

export { Price }