Format numbers, dates and currency
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; the free functions come from @comvi/core and work
anywhere.
Numbers and currency
Section titled “Numbers and currency”import { useI18n } from '@comvi/react';
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:
import { createI18n, formatCurrency, formatNumber } from '@comvi/core';
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
Section titled “Dates and relative time”import { useI18n } from '@comvi/react';
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.
import { formatRelativeTime } from '@comvi/core';import { i18n } from './i18n';
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
Section titled “Outside a component”import { createI18n, formatCurrency } from '@comvi/core';
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 overrideThe 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.
Text direction
Section titled “Text direction”import type { ReactNode } from 'react';import { useI18n } from '@comvi/react';
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
Section titled “Inside a translation”import { useI18n } from '@comvi/react';
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
Section titled “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
Section titled “Read next”- Pluralize with ICU:
plural,selectandselectordinalinside messages. - Switch the locale:
localeChanged, anddirandlangon the document. @comvi/react:useFormatters()and the bound formatter shapes.