Skip to content
UI

Format

beta

Formats 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
CallResult
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): string

formatNumber 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.

ParameterType
valuenumber | bigint

The number.

optionsFormatNumberOptions

The locale, and any Intl.NumberFormat option.

Returns string
The number as the reader writes it: "1,204", "1.204".

formatCompactNumber

formatCompactNumber(value: number, options: FormatCompactNumberOptions): string

formatCompactNumber 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.

ParameterType
valuenumber

The number.

optionsFormatCompactNumberOptions

The locale and the fraction digits, 1 by default.

Returns string
The short form of the number.

formatDate

formatDate(value: DateValue, options: FormatDateOptions): string

formatDate 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.

ParameterType
valueDateValue

The moment: a Date, epoch milliseconds or an ISO string with a time and an offset.

optionsFormatDateOptions

The 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): string

formatDateTime 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.

ParameterType
valueDateValue

The moment: a Date, epoch milliseconds or an ISO string with a time and an offset.

optionsFormatDateTimeOptions

The 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): string

formatTime formats the time of an absolute moment in the reader's time zone: "15:05" in pt-BR, "3:05 PM" in en-US.

ParameterType
valueDateValue

The moment: a Date, epoch milliseconds or an ISO string with a time and an offset.

optionsFormatTimeOptions

The 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): string

formatCalendarDate 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.

ParameterType
valuestring

The date, YYYY-MM-DD.

optionsFormatCalendarDateOptions

The 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): string

formatRelativeTime 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.

ParameterType
valueDateValue

The moment: a Date, epoch milliseconds or an ISO string with a time and an offset.

optionsFormatRelativeTimeOptions

The locale, the moment to measure from (now) and the style.

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): T

selectPlural 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.

ParameterType
countnumber

The number the text is about.

formsPluralForms<T>

The forms, by plural category; other is required.

optionsLocaleOptions

The 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.

FieldType
localestring

The BCP 47 tag of the reader's language: en-US, pt-BR, es-MX.

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.

FieldType
secondsoptionalboolean

Shows the seconds, for lists people investigate. Off by default.

hourCycleoptional"h12" | "h23"

Forces a 12-hour (h12) or 24-hour (h23) clock. By default the clock follows the locale: 24-hour in pt-BR and es-ES, 12-hour in en-US and es-MX.

DateValue

Date | number | string

DateValue 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 & TimeOptions

FormatDateTimeOptions are the options of formatDateTime.

FormatTimeOptions

ZonedOptions & TimeOptions

FormatTimeOptions 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.