Product/ui
Price tag
Locale-aware price with compare-at strike, computed discount badge and a screen-reader announcement. No dependencies.
Step 1Register the namespace — once per project
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonStep 2Add the block
$ npx shadcn@latest add @ecomcn/price-tagPreview — live
100%
Props
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. */
locale?: string
size?: "sm" | "lg"Source
components/ecomcn/price-tag.tsximport * as React from "react"
import { cn } from "@/lib/utils"
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. */
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, size = "lg", className, ...props },
ref
) => {
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"