Product/ui
Price tag
Locale-aware price with compare-at strike, computed discount badge and a screen-reader announcement. No dependencies.
$ npx shadcn@latest add @ecomcn/price-tagOlder shadcn CLI, or want the registry pinned?
Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonPreview — live
100%
Usage
Anywhere a price appearsimport { PriceTag } from "@/components/ecomcn/price-tag"
<PriceTag price={195} />
<PriceTag price={195} compareAt={240} /> // struck-through was-price
<PriceTag price={1240.5} currency="EUR" locale="de-DE" /> // 1.240,50 €
<PriceTag price={128} size="sm" /> // inline, in a card or a lineProps
Read straight out of the source at build time, so this cannot drift from the file you install.
PriceTagProps/** Current price, in major units (e.g. 42.5 for $42.50). */
price: number
/** Was-price. Rendered struck through only when it is higher than `price`. */
compareAt?: number
/** ISO 4217 code. Never hardcode a symbol — stores are not all in the US. */
currency?: string
/** BCP 47 tag. `undefined` follows the visitor's locale, once hydrated. */
locale?: string
size?: "sm" | "lg"Source
components/ecomcn/price-tag.tsx"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const subscribeNothing = () => () => {}
/**
* Your locale, or the visitor's once hydrated. The server can't know the
* visitor's locale, and its HTML must match the first client render — a
* server in en-IN prints "1,24,000" where an en-US browser prints
* "124,000" — so until hydration it formats in en-US.
*/
function useFormatLocale(locale?: string) {
const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
return locale ?? (hydrated ? undefined : "en-US")
}
export interface PriceTagProps extends React.HTMLAttributes<HTMLDivElement> {
/** Current price, in major units (e.g. 42.5 for $42.50). */
price: number
/** Was-price. Rendered struck through only when it is higher than `price`. */
compareAt?: number
/** ISO 4217 code. Never hardcode a symbol — stores are not all in the US. */
currency?: string
/** BCP 47 tag. `undefined` follows the visitor's locale, once hydrated. */
locale?: string
size?: "sm" | "lg"
}
/**
* The single most-copied component in any storefront, and the one most often
* shipped with a hardcoded dollar sign. Everything here goes through Intl.
*/
export const PriceTag = React.forwardRef<HTMLDivElement, PriceTagProps>(
(
{ price, compareAt, currency = "USD", locale: localeProp, size = "lg", className, ...props },
ref
) => {
const locale = useFormatLocale(localeProp)
const format = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
}),
[locale, currency, price]
)
const onSale = typeof compareAt === "number" && compareAt > price
const percentOff = onSale ? Math.round((1 - price / compareAt) * 100) : 0
return (
<div
ref={ref}
className={cn("flex flex-wrap items-baseline gap-x-3 gap-y-1", className)}
{...props}
>
<span
className={cn(
"tabular-nums",
size === "lg" ? "text-3xl" : "text-lg",
onSale && "text-[var(--sale)]"
)}
>
{format.format(price)}
</span>
{onSale ? (
<>
<span
aria-hidden
className="text-sm text-muted-foreground line-through tabular-nums"
>
{format.format(compareAt)}
</span>
<span
aria-hidden
className="bg-[var(--sale)] px-1.5 py-0.5 text-[11px] uppercase tracking-[0.14em] text-background tabular-nums"
>
Save {percentOff}%
</span>
{/* one clean announcement instead of three fragments */}
<span className="sr-only">
Reduced from {format.format(compareAt)}, {percentOff} percent off
</span>
</>
) : null}
</div>
)
}
)
PriceTag.displayName = "PriceTag"