# Format

Formats numbers and dates for people to read, in the reader's locale and time zone, on Intl with no dependency.

```tsx
import * as React from "react"
import { Field, FieldLabel } from "@zenoxai/ui/components/field"
import { NativeSelect, NativeSelectOption } from "@zenoxai/ui/components/native-select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table"
import { formatCalendarDate, formatCompactNumber, formatDate, formatDateTime, formatNumber, formatRelativeTime, formatTime, selectPlural } from "@zenoxai/ui/lib/format"

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

```tsx
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](/patterns/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](/guidelines/data-display#dates-and-times) 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"`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

`import { ... } from "@zenoxai/ui/lib/format"`

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

| Parameter | Type | Description |
| --- | --- | --- |
| value | `number \| bigint` | The number. |
| options | `FormatNumberOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| value | `number` | The number. |
| options | `FormatCompactNumberOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| value | `DateValue` | The moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
| options | `FormatDateOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| value | `DateValue` | The moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
| options | `FormatDateTimeOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| value | `DateValue` | The moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
| options | `FormatTimeOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| value | `string` | The date, `YYYY-MM-DD`. |
| options | `FormatCalendarDateOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| value | `DateValue` | The moment: a Date, epoch milliseconds or an ISO string with a time and an offset. |
| options | `FormatRelativeTimeOptions` | 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.

| Parameter | Type | Description |
| --- | --- | --- |
| count | `number` | The number the text is about. |
| forms | `PluralForms<T>` | The forms, by plural category; `other` is required. |
| options | `LocaleOptions` | 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`.

| Field | Type | Description |
| --- | --- | --- |
| locale | `string` | 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.

| Field | Type | Description |
| --- | --- | --- |
| seconds (optional) | `boolean` | Shows the seconds, for lists people investigate. Off by default. |
| hourCycle (optional) | `"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.

