# Format numbers, dates and currency

Format numbers, currency, dates and relative time in the current locale, read the text direction, and pass formatted values into t().

Canonical: https://comvi.io/docs/i18n/formatting

Format numbers, dates, currency and relative time in the current locale with the `Intl`
wrappers that `useI18n()` returns. The examples assume `@comvi/react` and the instance from the
[Quick start](/docs/i18n/quick-start); the free functions come from `@comvi/core` and work
anywhere.

## Numbers and currency

`src/PriceTag.tsx`

```tsx

export function PriceTag({ amount }: { amount: number }) {
const { formatNumber, formatCurrency } = useI18n();
return (
    <>
      <span>{formatNumber(amount, { maximumFractionDigits: 2 })}</span>
      <span>{formatCurrency(amount, 'EUR')}</span>
    </>
);
}
```

Each bound formatter follows the current locale, so the output changes with a locale switch on
its own. The `options` argument forwards to the `Intl` constructor:

`src/patterns.ts`

```ts

const i18n = createI18n({ locale: 'en' });

formatNumber(i18n, 0.85, { style: 'percent' }); // "85%"
formatNumber(i18n, 1234.5, { maximumFractionDigits: 2 }); // "1,234.5" (en), "1 234,5" (fr)
formatNumber(i18n, 2.5, { style: 'unit', unit: 'kilometer' }); // "2.5 km"
formatNumber(i18n, 1_500_000, { notation: 'compact' }); // "1.5M"

formatCurrency(i18n, 1234.5, 'USD'); // "$1,234.50"
formatCurrency(i18n, 1234.5, 'EUR'); // "€1,234.50" (en), "1 234,50 €" (fr)
formatCurrency(i18n, 1234.5, 'UAH', { currencyDisplay: 'code' }); // "UAH 1,234.50"
```

`formatCurrency` takes the ISO 4217 code as its own argument and sets `style: 'currency'` for
you; other `Intl.NumberFormatOptions` such as `minimumFractionDigits` still apply. Keep the
currency in your data, never in a translation string.

## Dates and relative time

`src/Shipped.tsx`

```tsx

export function Shipped({ at }: { at: Date }) {
const { formatDate, formatRelativeTime } = useI18n();
return (
    <>
      <time>{formatDate(at, { dateStyle: 'long' })}</time>
      <time>{formatDate(at, { dateStyle: 'medium', timeStyle: 'short' })}</time>
      <span>{formatRelativeTime(-2, 'hour')}</span>
      <span>{formatRelativeTime(-1, 'day', { numeric: 'auto' })}</span>
    </>
);
}
```

`formatDate` takes a `Date` or epoch milliseconds and any `Intl.DateTimeFormatOptions`.
`formatRelativeTime` takes a signed amount and a unit: `-2, 'hour'` is "2 hours ago" and
`3, 'day'` is "in 3 days". `numeric: 'auto'` renders "yesterday" and "today" where the locale
has such words; the default `'always'` keeps the numeric form.

`formatRelativeTime` does no arithmetic: it renders the number and unit you pass, so the
helper that picks a unit from a time difference lives in your code.

`src/ago.ts`

```ts

export function formatAgo(date: Date) {
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
const abs = Math.abs(diffSec);
if (abs < 60) return formatRelativeTime(i18n, diffSec, 'second');
if (abs < 3600) return formatRelativeTime(i18n, Math.round(diffSec / 60), 'minute');
if (abs < 86_400) return formatRelativeTime(i18n, Math.round(diffSec / 3600), 'hour');
return formatRelativeTime(i18n, Math.round(diffSec / 86_400), 'day');
}
```

## Outside a component

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'de' });

formatCurrency(i18n, 1234.5, 'EUR'); // "1.234,50 €"
formatCurrency({ locale: 'en-US' }, 1234.5, 'EUR'); // "€1,234.50"
formatCurrency(i18n, 1234.5, 'EUR', undefined, 'en-US'); // one-off locale override
```

The root exports of `@comvi/core` take the locale source first: the instance, a hook's return
value, or any object with a `locale` string. They read `locale` at call time, so in vanilla
code re-run the call on the `localeChanged` event. A trailing `locale` argument overrides the
locale for one call, on the root exports and on the formatters `useI18n()` returns in every
binding; React's `useFormatters()` takes no trailing locale and always formats in the render locale.

> **No formatter methods on the instance**
>
> A core instance has no `i18n.formatNumber` and no `i18n.dir`. Both read as `undefined` with
> no error, and `dir={i18n.dir}` sets an empty attribute that leaves an RTL page laid out left to
> right. Call the root exports with the instance as the first argument, or use the bound
> formatters from `useI18n()`.

## Text direction

`src/Layout.tsx`

```tsx

export function Layout({ children }: { children: ReactNode }) {
const { dir } = useI18n();
return <div dir={dir}>{children}</div>;
}
```

`dir` is `'rtl'` for Arabic, Hebrew, Persian, Urdu and every other CLDR right-to-left locale,
and `'ltr'` otherwise. It updates with the locale. Outside a component, `getTextDirection(locale)`
from `@comvi/core` returns the same value for a locale string, including script and region
subtags such as `ku-Arab` or `he-IL`. Pair it with CSS logical properties such as
`margin-inline-start` so layouts mirror without per-direction overrides.

## Inside a translation

`src/OrdersShipped.tsx`

```tsx

export function OrdersShipped({ count, date }: { count: number; date: Date }) {
const { t, formatNumber, formatDate } = useI18n();
// "orders.shipped": "{count} orders shipped on {date}"
return (
    <p>
      {t('orders.shipped', {
        count: formatNumber(count),
        date: formatDate(date, { dateStyle: 'long' }),
      })}
    </p>
);
}
```

Translation strings do not evaluate ICU number or date skeletons. Format the value first and
pass the string as a parameter. A `plural` argument is the exception: pass the raw number so
the plural rule can inspect it.

## How it works

Every formatter is a thin wrapper over an `Intl` constructor. `formatNumber` builds an
`Intl.NumberFormat`, `formatDate` an `Intl.DateTimeFormat`, `formatRelativeTime` an
`Intl.RelativeTimeFormat`, and `formatCurrency` is `formatNumber` with `style: 'currency'` and
your code merged into the options. The constructors carry the locale's CLDR data, which is why
the grouping separator, the decimal mark and the position of the currency sign differ between
`en` and `fr` with no option on your side. The library adds no formatting rules of its own, so
what `Intl` renders in your runtime is what the user sees.

An `Intl` object is built once per locale and options and kept in a bounded cache shared by
every instance in the process, keyed by the locale and the serialized options. Formatting a
column of prices costs a lookup per cell, not a constructor call, and the cache evicts its
oldest entry when it is full, so a page with many distinct option objects stays bounded in
memory rather than growing without end.

A bound formatter from `useI18n()` is a closure over the instance and the locale the binding
currently tracks. It calls the root export with that locale as the trailing argument, so a
locale switch re-renders the component and the next call formats in the new locale without
anything on your side. The root exports take the locale source first and read its `locale`
property at call time, which is why any object with a `locale` string works there and why
vanilla code re-runs the call on `localeChanged`. In Solid the bound formatters read the locale
signal at call time, so whatever JSX expression, memo or effect calls them re-runs on a switch.

`formatDate` formats in the runtime's time zone unless `timeZone` is in the options, so a
server render and a client render in different zones can disagree by a day. `getTextDirection`
asks `Intl.Locale` for the locale's text direction where the runtime has it, which covers
script subtags such as `ku-Arab`, region tags such as `he-IL` and every CLDR right-to-left
locale that a hand-written list would miss; on an older runtime it falls back to a check of
the script subtag and then of the language code.

`t()` pastes a parameter into the message as a string, with no second pass over it, so a
formatted value arrives exactly as `formatNumber` or `formatDate` produced it. The `#` inside a
`plural` branch is the raw count as `String(count)`, with no grouping, so a message that shows
large counts formats the number separately and passes it under a second name. Both the
translation and the formatted value follow the same locale, so a switch re-renders them
together.

## Read next

- [Pluralize with ICU](/docs/i18n/pluralization): `plural`, `select` and `selectordinal` inside messages.
- [Switch the locale](/docs/i18n/language-switching): `localeChanged`, and `dir` and `lang` on the document.
- [`@comvi/react`](/docs/i18n/api/react#useformatters): `useFormatters()` and the bound formatter shapes.
