Patterns
PatternsTier 5 · Blocks
Price
Rupee formatting, compare-at strikethrough and the saving line.
bunx --bun shadcn@latest add @woohl/pricePreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/priceregistry/tier-5-blocks/price.tsx
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { cva, type VariantProps } from "@/registry/tier-2-primitives/lib/cva";
import { formatPrice } from "@/registry/tier-1-tokens/lib/constants";
/**
* Price.
*
* Woohl prices in rupees, formatted the Indian way — ₹1,20,000, not ₹120,000.
* `Intl` with the `en-IN` locale gets the lakh/crore grouping right; every
* hand-rolled `toLocaleString` eventually doesn't.
*
* THE STRUCK-THROUGH PRICE IS NOT DECORATION
* When `compareAt` is set, the original is marked up as `<s>` and given
* screen-reader text, so a non-visual reader hears "₹1,400, now ₹1,180"
* rather than two numbers in a row with no relationship. A line through a
* number is a visual convention that carries no meaning to a screen reader on
* its own.
*/
const priceVariants = cva("inline-flex items-baseline gap-2 font-ui", {
variants: {
size: {
sm: "text-body",
md: "text-body-lg",
lg: "text-h3",
xl: "text-h2",
},
tone: {
/** The default. Price is the accent's most common job on a listing. */
accent: "text-text-accent",
/** In a table, a summary line, or anywhere orange would be the fifth
orange thing on screen. */
primary: "text-text-primary",
},
},
defaultVariants: { size: "md", tone: "accent" },
});
export type PriceProps = VariantProps<typeof priceVariants> & {
/** In rupees. */
amount: number;
/** The original price, when this is a reduction. Struck through. */
compareAt?: number;
/** "per kg", "per piece" — bulk listings. */
unit?: string;
showDecimals?: boolean;
className?: string;
};
export function Price({
amount,
compareAt,
unit,
size,
tone,
showDecimals,
className,
}: PriceProps) {
const now = formatPrice(amount, { showDecimals });
const before =
compareAt !== undefined ? formatPrice(compareAt, { showDecimals }) : null;
return (
<span
data-slot="price"
className={cn(priceVariants({ size, tone }), className)}
>
{before && <span className="sr-only">Was {before}, now </span>}
<span className="font-extrabold">{now}</span>
{unit && (
<span className="text-caption text-text-muted font-normal">{unit}</span>
)}
{before && (
<s aria-hidden className="text-text-muted text-[0.82em] font-normal">
{before}
</s>
)}
</span>
);
}
/**
* The saving, as a badge-shaped line: "Save ₹220 (16%)".
*
* Stated in rupees first, because that is the number a buyer is actually
* weighing — the percentage is context for it.
*/
export function PriceSaving({
amount,
compareAt,
className,
}: {
amount: number;
compareAt: number;
className?: string;
}) {
if (compareAt <= amount) return null;
const saved = compareAt - amount;
const percent = Math.round((saved / compareAt) * 100);
return (
<span
className={cn("font-ui text-caption text-success font-bold", className)}
>
Save {formatPrice(saved)} ({percent}%)
</span>
);
}
PriceProps
| Prop | Type | Default |
|---|---|---|
amount*In rupees. | number | — |
className | string | — |
compareAtThe original price, when this is a reduction. Struck through. | number | — |
showDecimals | boolean | — |
size | "sm" | "md" | "lg" | "xl" | "md" |
tone | "accent" | "primary" | "accent" |
unit"per kg", "per piece" — bulk listings. | string | — |
Registry items
npm packages
None beyond React itself.