Format
betaFormats numbers and dates for people to read, in the reader's locale and time zone, on Intl with no dependency.
@zenoxai/ui/lib/format Markdown| Call | Result |
|---|---|
| formatNumber(1204) | 1.204 |
| formatCompactNumber(1204) | 1,2 mil |
| formatDateTime(detectedAt) | 27/10/2026 15:05 |
| formatDateTime(detectedAt, { seconds: true }) | 27/10/2026 15:05:09 |
| formatDate(detectedAt, { style: "medium" }) | 27 de out. de 2026 |
| formatTime(detectedAt) | 15:05 |
| formatCalendarDate("2026-12-31") | 31/12/2026 |
| formatRelativeTime(detectedAt, { now }) | há 2 horas |
| selectPlural(1, { one, other }) | alert |
function FormatDemo() {
const [locale, setLocale] = React.useState("pt-BR")
const [timeZone, setTimeZone] = React.useState("America/Sao_Paulo")
// When the alert was detected, and the moment the page measures from.
const detectedAt = "2026-10-27T18:05:09Z"
const now = Date.parse("2026-10-27T20:05:09Z")
const zoned = { locale, timeZone }
const rows = [
["formatNumber(1204)", formatNumber(1204, { locale })],
["formatCompactNumber(1204)", formatCompactNumber(1204, { locale })],
["formatDateTime(detectedAt)", formatDateTime(detectedAt, zoned)],
[
"formatDateTime(detectedAt, { seconds: true })",
formatDateTime(detectedAt, { ...zoned, seconds: true }),
],
[
'formatDate(detectedAt, { style: "medium" })',
formatDate(detectedAt, { ...zoned, style: "medium" }),
],
["formatTime(detectedAt)", formatTime(detectedAt, zoned)],
[
'formatCalendarDate("2026-12-31")',
formatCalendarDate("2026-12-31", { locale }),
],
[
"formatRelativeTime(detectedAt, { now })",
formatRelativeTime(detectedAt, { locale, now }),
],
[
"selectPlural(1, { one, other })",
selectPlural(1, { one: "alert", other: "alerts" }, { locale }),
],
]
return (
<div className="flex w-full flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="format-locale">Locale</FieldLabel>
<NativeSelect
id="format-locale"
value={locale}
onChange={(event) => setLocale(event.target.value)}
>
<NativeSelectOption value="en-US">en-US</NativeSelectOption>
<NativeSelectOption value="pt-BR">pt-BR</NativeSelectOption>
<NativeSelectOption value="es-ES">es-ES</NativeSelectOption>
<NativeSelectOption value="es-MX">es-MX</NativeSelectOption>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="format-time-zone">Time zone</FieldLabel>
<NativeSelect
id="format-time-zone"
value={timeZone}
onChange={(event) => setTimeZone(event.target.value)}
>
<NativeSelectOption value="America/Sao_Paulo">
America/Sao_Paulo
</NativeSelectOption>
<NativeSelectOption value="America/Mexico_City">
America/Mexico_City
</NativeSelectOption>
<NativeSelectOption value="Europe/Madrid">
Europe/Madrid
</NativeSelectOption>
<NativeSelectOption value="UTC">UTC</NativeSelectOption>
</NativeSelect>
</Field>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Call</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(([call, result]) => (
<TableRow key={call}>
<TableCell className="font-mono text-text-secondary">
{call}
</TableCell>
<TableCell className="tabular-nums">{result}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Usage
import {
formatDateTime,
formatNumber,
} from "@zenoxai/ui/lib/format"
const options = { locale: "pt-BR", timeZone: "America/Sao_Paulo" }
formatNumber(1204, { locale: "pt-BR" }) // "1.204"
formatDateTime("2026-10-27T18:05:09Z", options) // "27/10/2026 15:05"
formatDateTime(alert.detectedAt, { ...options, seconds: true }) // "27/10/2026 15:05:09"
Every function takes the reader’s locale, a BCP 47 tag, and the zoned ones their time zone, an IANA name. Both are required: the result never depends on the machine it runs on, so a Server Component and the browser render the same text. Read them from the user’s profile once, in the app, and pass them down.
| Function | Use for | Example |
|---|---|---|
formatNumber |
Counts, totals and any exact number | 1.204 |
formatCompactNumber |
Stat Tile figures and chart axes only | 1,2 mil |
formatDateTime |
An absolute moment: an event, an alert, an audit entry | 27/10/2026 15:05:09 |
formatDate |
The date of a moment, in the reader’s zone | 27/10/2026 |
formatTime |
The time of a moment | 15:05 |
formatCalendarDate |
A date with no moment: an expiry, a filter’s date, YYYY-MM-DD |
31/12/2026 |
formatRelativeTime |
Freshness only, with the absolute moment beside it or in a tooltip | há 2 horas |
selectPlural |
Picking the singular or plural form of a text for a count | alerta, alertas |
See data display for when to show which.
These are plain functions, with no React and no DOM: they run in a Server Component, a worker and the browser alike. Formatters are created once per locale and options, in a bounded cache, so formatting every row of a table is cheap.
Examples
The clock follows the locale
The time is 24-hour in pt-BR and es-ES and 12-hour in en-US and es-MX, as readers of each expect.
A 24-hour clock writes the hour in two digits (“09:05”). To use 24-hour time everywhere, pass
hourCycle: "h23".
formatDateTime(moment, { locale: "es-MX", timeZone }) // "27/10/2026 3:05 p.m."
formatDateTime(moment, { locale: "es-MX", timeZone, hourCycle: "h23" }) // "27/10/2026 15:05"
The default style is numeric with a four-digit year, the date and the time joined by a space.
For prose and headers, style: "medium" spells the month: “27 de out. de 2026”.
A date with no moment
An expiry, a birthday or a filter’s date is a day, not a moment. Read as a moment, 2026-10-27
becomes midnight UTC, which is still the 26th in São Paulo. formatCalendarDate formats the day
as written, with no time zone, and the moment functions refuse a date-only string.
formatCalendarDate("2026-10-27", { locale: "pt-BR" }) // "27/10/2026", wherever it runs
formatDate("2026-10-27", options) // "", and a warning in development
Relative time
formatRelativeTime counts whole units of the largest unit that fits: “2 hours ago”, “yesterday”,
“last month”. It takes now, so it stays pure: pass the current time from the component that
renders it, and update it on an interval if the text must stay fresh.
formatRelativeTime(device.lastSeenAt, { locale, now: Date.now() }) // "há 2 horas"
Plurals
selectPlural picks a form by the plural rules of the language, Intl.PluralRules. Pass the
forms from the app’s dictionary; other is required. Portuguese treats 0 as singular, and
Spanish puts a million in many, so never test count === 1 by hand.
const noun = selectPlural(count, { one: t("alert"), other: t("alerts") }, { locale })
const text = `${formatNumber(count, { locale })} ${noun}`
Invalid input
An invalid locale (pt_BR) falls back to en-US, and an invalid time zone (America/Sao Paulo) to
UTC, with one warning in development. A value that is not a moment gives an empty string. None
of the functions throw, so a bad profile setting never unmounts a page.
API reference
Generated from the TypeScript source and its JSDoc.
formatNumber
formatNumber(value: number | bigint, options: FormatNumberOptions): stringformatNumber formats an exact number, such as a count or a total, in the reader's locale. It groups thousands always, so Spanish writes "1.204" as Portuguese does, where Intl alone would leave four-digit numbers ungrouped. Other Intl.NumberFormat options pass through.
| Parameter | Type |
|---|---|
value | number | bigintThe number. |
options | FormatNumberOptionsThe locale, and any |
- Returns
string - The number as the reader writes it: "1,204", "1.204".
formatCompactNumber
formatCompactNumber(value: number, options: FormatCompactNumberOptions): stringformatCompactNumber shortens a large number for Stat Tile and chart axes only: "1.2K", "1,2 mil". Tables and counts use formatNumber, which keeps every digit.
| Parameter | Type |
|---|---|
value | numberThe number. |
options | FormatCompactNumberOptionsThe locale and the fraction digits, 1 by default. |
- Returns
string - The short form of the number.
formatDate
formatDate(value: DateValue, options: FormatDateOptions): stringformatDate formats the date of an absolute moment in the reader's time zone: "27/10/2026" in pt-BR, "10/27/2026" in en-US, with style: "medium" "27 de out. de 2026". For a date that has no moment, such as an expiry, use formatCalendarDate.
| Parameter | Type |
|---|---|
value | DateValueThe moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
options | FormatDateOptionsThe locale, the time zone and the style. |
- Returns
string - The date, or an empty string when `value` is not a moment.
formatDateTime
formatDateTime(value: DateValue, options: FormatDateTimeOptions): stringformatDateTime formats an absolute moment, date and time, in the reader's time zone. The default style joins the numeric date and the time with a space: "27/10/2026 15:05" in pt-BR, "10/27/2026 3:05 PM" in en-US. The clock follows the locale unless hourCycle forces one.
| Parameter | Type |
|---|---|
value | DateValueThe moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
options | FormatDateTimeOptionsThe locale, the time zone, the style, and whether to show seconds. |
- Returns
string - The date and time, or an empty string when `value` is not a moment.
formatTime
formatTime(value: DateValue, options: FormatTimeOptions): stringformatTime formats the time of an absolute moment in the reader's time zone: "15:05" in pt-BR, "3:05 PM" in en-US.
| Parameter | Type |
|---|---|
value | DateValueThe moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
options | FormatTimeOptionsThe locale, the time zone, and whether to show seconds. |
- Returns
string - The time, or an empty string when `value` is not a moment.
formatCalendarDate
formatCalendarDate(value: string, options: FormatCalendarDateOptions): stringformatCalendarDate formats a date that has no moment, such as an expiry or a filter's date, written YYYY-MM-DD. It converts no time zone, so the day never moves: "2026-10-27" is "27/10/2026" wherever it runs.
| Parameter | Type |
|---|---|
value | stringThe date, |
options | FormatCalendarDateOptionsThe locale and the style. |
- Returns
string - The date, or an empty string when `value` is not a valid date.
formatRelativeTime
formatRelativeTime(value: DateValue, options: FormatRelativeTimeOptions): stringformatRelativeTime says how long ago or how soon a moment is, for freshness only: "2 hours ago", "há 2 horas", "yesterday". It counts whole units of the largest unit that fits. Show the absolute moment beside it, or in a tooltip, wherever the exact time matters.
| Parameter | Type |
|---|---|
value | DateValueThe moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
options | FormatRelativeTimeOptionsThe locale, the moment to measure from ( |
- Returns
string - The distance in words, or an empty string when `value` is not a moment.
selectPlural
selectPlural(count: number, forms: PluralForms<T>, options: LocaleOptions): TselectPlural picks the form of a text for a count, by the plural rules of the reader's language: one and other in English and Portuguese, more in other languages. Use it in a label function, with the forms from the app's dictionary. Format the count itself with formatNumber.
| Parameter | Type |
|---|---|
count | numberThe number the text is about. |
forms | PluralForms<T>The forms, by plural category; |
options | LocaleOptionsThe locale whose rules apply. |
- Returns
T - The form for the count, or `other` when that category is missing.
LocaleOptions
LocaleOptions holds the reader's language, a BCP 47 tag such as pt-BR.
| Field | Type |
|---|---|
locale | stringThe BCP 47 tag of the reader's language: |
ZonedOptions
LocaleOptions & {
/** The IANA name of the reader's time zone: `America/Sao_Paulo`. */
timeZone: string
}ZonedOptions adds the reader's time zone, for absolute moments.
DateStyle
"short" | "medium" | "long"DateStyle picks how a date reads. short (the default) is the numeric date of lists and tables, with a four-digit year: 27/10/2026, 10/27/2026. medium and long spell the month, for prose and headers: 27 de out. de 2026, October 27, 2026.
TimeOptions
TimeOptions sets how the time of a moment reads.
| Field | Type |
|---|---|
secondsoptional | booleanShows the seconds, for lists people investigate. Off by default. |
hourCycleoptional | "h12" | "h23"Forces a 12-hour ( |
DateValue
Date | number | stringDateValue is a moment: a Date, epoch milliseconds, or an ISO string with a time and an offset (2026-10-27T18:05:09Z). A date with no time, such as 2026-10-27, is not a moment: format it with formatCalendarDate.
FormatNumberOptions
LocaleOptions &
Omit<Intl.NumberFormatOptions, "localeMatcher">FormatNumberOptions are Intl.NumberFormat options plus the locale.
FormatCompactNumberOptions
LocaleOptions & {
/** Digits after the decimal separator, 1 by default: "1,2 mil". */
maximumFractionDigits?: number
}FormatCompactNumberOptions are the options of formatCompactNumber.
FormatDateOptions
ZonedOptions & {
/** How the date reads. `short` by default. */
style?: DateStyle
}FormatDateOptions are the options of formatDate.
FormatDateTimeOptions
FormatDateOptions & TimeOptionsFormatDateTimeOptions are the options of formatDateTime.
FormatTimeOptions
ZonedOptions & TimeOptionsFormatTimeOptions are the options of formatTime.
FormatCalendarDateOptions
LocaleOptions & {
/** How the date reads. `short` by default. */
style?: DateStyle
}FormatCalendarDateOptions are the options of formatCalendarDate.
FormatRelativeTimeOptions
LocaleOptions & {
/**
* The moment to measure from, usually the current time. It is required, so
* the function stays pure and renders the same on the server and the client.
*/
now: Date | number
/** The length of the unit names: `long` (the default), `short` or `narrow`. */
style?: Intl.RelativeTimeFormatStyle
}FormatRelativeTimeOptions are the options of formatRelativeTime.
PluralForms
Partial<Record<Intl.LDMLPluralRule, T>> & {
other: T
}PluralForms holds the forms of one text, one per plural category the reader's language has (Intl.PluralRules). other is required: it is the form every language has, and the one used when a category is missing.