Formatted US dollars from an integer amount of cents, rendered in a data element with a plain decimal value, in three sizes.

pnpm add @behivetech/atoms.price

You are viewing Price v0.1.0, snapshotted 2026-11-09. The latest is v0.1.1. Go to the latest →

registry/behivetech/atoms/price at v0.1.0, snapshotted 2026-11-09

price.tsx
1import { type HTMLAttributes } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./price.module.scss";
4
5/** The type sizes a Price can render at. */
6export type PriceSize = "sm" | "md" | "lg";
7
8const DEFAULT_LOCALE = "en-US";
9const CURRENCY = "USD";
10const FRACTION_DIGITS = 2;
11
12const formatters = new Map<string, Intl.NumberFormat>();
13
14/** Intl.NumberFormat v3 formats a decimal string exactly; this TypeScript lib only types number | bigint. */
15type DecimalFormatter = { format: (value: string) => string };
16
17/** Returns the cached USD formatter for a locale, so long lists build each one once. */
18const getFormatter = (locale: string) => {
19 const formatter =
20 formatters.get(locale) ??
21 new Intl.NumberFormat(locale, { style: "currency", currency: CURRENCY });
22 formatters.set(locale, formatter);
23 return formatter;
24};
25
26/**
27 * Builds the exact decimal string for an integer amount of cents without
28 * floating-point division, so large amounts keep every cent and -0 becomes 0.
29 */
30const toDecimal = (amountCents: number) => {
31 const rounded = Math.round(amountCents) + 0;
32 const digits = String(Math.abs(rounded)).padStart(FRACTION_DIGITS + 1, "0");
33 const whole = digits.slice(0, -FRACTION_DIGITS);
34 const fraction = digits.slice(-FRACTION_DIGITS);
35 const sign = rounded < 0 ? "-" : "";
36 return `${sign}${whole}.${fraction}`;
37};
38
39/** Props for Price: an amount of cents, a locale and a size. */
40export interface PriceProps extends HTMLAttributes<HTMLDataElement> {
41 /** The amount in cents, as an integer; other values are rounded */
42 amountCents: number;
43 /** The currency code; only USD is supported, so it defaults to USD until a second currency is added */
44 currency?: typeof CURRENCY;
45 /** BCP 47 locale the amount is formatted in; defaults to en-US so the server render and hydration match */
46 locale?: string;
47 /** Controls the type size; defaults to md */
48 size?: PriceSize;
49 /** Additional class names to merge with the component root element */
50 className?: string;
51}
52
53/**
54 * Formats an amount of minor units as money for a locale and renders it in a
55 * `<data>` element whose `value` is the plain decimal, so it stays readable by
56 * machines whatever the locale.
57 */
58export const Price = ({
59 amountCents,
60 currency = CURRENCY,
61 locale = DEFAULT_LOCALE,
62 size = "md",
63 className,
64 ...rest
65}: PriceProps) => {
66 const [rootClass] = getClassName({
67 className,
68 rootClass: "BHT__Price",
69 modifiers: { [size]: true },
70 styles,
71 });
72
73 const isValid = Number.isFinite(amountCents) && currency === CURRENCY;
74 const decimal = isValid ? toDecimal(amountCents) : "";
75 const price = isValid ? (
76 <data {...rest} className={rootClass} value={decimal}>
77 {(getFormatter(locale) as unknown as DecimalFormatter).format(decimal)}
78 </data>
79 ) : null;
80
81 return price;
82};
price.module.scss
1// Material Design 3 type roles per size: sm = title-small, md = title-medium,
2// lg = headline-small. The colour is always on-surface.
3
4.BHT__Price {
5 display: inline-block;
6 color: var(--md-sys-color-on-surface, var(--color-text-heading));
7 white-space: nowrap;
8 font-variant-numeric: tabular-nums;
9
10 &--sm {
11 font-family: var(--md-sys-typescale-title-small-font, var(--font-body));
12 font-size: var(--md-sys-typescale-title-small-size, 0.875rem);
13 font-weight: var(--md-sys-typescale-title-small-weight, 600);
14 line-height: var(--md-sys-typescale-title-small-line-height, 1.25rem);
15 letter-spacing: var(--md-sys-typescale-title-small-tracking, 0.0071em);
16 }
17
18 &--md {
19 font-family: var(--md-sys-typescale-title-medium-font, var(--font-body));
20 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
21 font-weight: var(--md-sys-typescale-title-medium-weight, 600);
22 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);
23 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.0094em);
24 }
25
26 &--lg {
27 font-family: var(
28 --md-sys-typescale-headline-small-font,
29 var(--font-heading)
30 );
31 font-size: var(--md-sys-typescale-headline-small-size, 1.5rem);
32 font-weight: var(--md-sys-typescale-headline-small-weight, 700);
33 line-height: var(--md-sys-typescale-headline-small-line-height, 2rem);
34 letter-spacing: var(--md-sys-typescale-headline-small-tracking, 0);
35 }
36}
index.ts
1export { Price } from "./price.js";
2export type { PriceProps, PriceSize } from "./price.js";
price.example.tsx
1import { Price } from "./price.js";
2
3/** The default md size, formatted from integer cents. */
4export const BasicPrice = () => <Price amountCents={125000} currency="USD" />;
5
6/** Every size: sm for list meta, md for checkout lines, lg for detail pages and totals. */
7export const PriceSizes = () => (
8 <>
9 <Price amountCents={125000} currency="USD" size="sm" />{" "}
10 <Price amountCents={125000} currency="USD" size="md" />{" "}
11 <Price amountCents={125000} currency="USD" size="lg" />
12 </>
13);
14
15/** The amount is formatted in the given locale; the value attribute stays a plain decimal. */
16export const PriceLocale = () => (
17 <Price amountCents={125000} currency="USD" locale="de-DE" />
18);