Skip to content
Comvi

Format numbers, dates and currency

View .md

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.

src/PriceTag.tsx
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:

src/patterns.ts
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.

src/Shipped.tsx
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.

src/ago.ts
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');
}
src/i18n.ts
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 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.

src/Layout.tsx
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.

src/OrdersShipped.tsx
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.

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.