# Number formatting

Locale-aware decimal, money, and accounting display with native Intl options.


Pretable formats numeric cells with the platform's native `Intl.NumberFormat`:
put `numberFormat` on a column and pass the app's `locale` to the surface. The
regional sales grid below runs three of them side by side — money on Revenue,
accounting on Refunds, and a raw percent on Margin:

### Example: Money, accounting, and percent formats

A regional sales grid using numberFormats.money, numberFormats.accounting, and a raw percent numberFormat, grouped by region so each aggregate row inherits its column's format with no formatAggregate callback.

Source: https://pretable.ai/examples/number-formatting.md

```tsx RegionalSalesGrid.tsx
"use client";

import { useState, type ComponentProps } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { orders, type Order } from "./data";

const VIEWPORT_HEIGHT = 340;

export function RegionalSalesGrid() {
  const [query, setQuery] = useState<
    NonNullable<ComponentProps<typeof PretableSurface<Order>>["query"]>
  >({
    filters: [],
    sort: [],
    rowGroups: [{ columnId: "region" }],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Revenue uses <code>numberFormats.money</code>, Refunds uses{" "}
        <code>numberFormats.accounting</code> (negative values print
        parenthesized), and Margin is a raw percent <code>numberFormat</code>.
        Grouped by region — every aggregate row below inherits its own
        column&apos;s format with no <code>formatAggregate</code> callback.
      </p>
      <PretableSurface<Order>
        ariaLabel="Regional sales"
        columns={columns}
        getRowId={(row) => row.id}
        groupColumn={{ header: "Region", widthPx: 200 }}
        locale="en-US"
        onQueryChange={setQuery}
        query={query}
        rows={orders}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import { numberFormats, type PretableColumn } from "@pretable/react";

import type { Order } from "./data";

export const columns: PretableColumn<Order>[] = [
  { id: "region", header: "Region" },
  { id: "channel", header: "Channel" },
  {
    id: "revenue",
    header: "Revenue",
    type: "number",
    aggregate: "sum",
    numberFormat: numberFormats.money({ currency: "USD" }),
  },
  {
    id: "refunds",
    header: "Refunds",
    type: "number",
    aggregate: "sum",
    numberFormat: numberFormats.accounting({ currency: "USD" }),
  },
  {
    id: "marginPct",
    header: "Margin",
    type: "number",
    aggregate: "avg",
    numberFormat: { style: "percent", maximumFractionDigits: 1 },
  },
];
```

```ts data.ts
export interface Order extends Record<string, unknown> {
  id: string;
  region: string;
  channel: string;
  revenue: number;
  refunds: number;
  marginPct: number;
}

export const orders: Order[] = [
  {
    id: "o1",
    region: "Northeast",
    channel: "Retail",
    revenue: 48200,
    refunds: -1250,
    marginPct: 0.212,
  },
  {
    id: "o2",
    region: "Northeast",
    channel: "Wholesale",
    revenue: 91500,
    refunds: -3100,
    marginPct: 0.164,
  },
  {
    id: "o3",
    region: "Northeast",
    channel: "Online",
    revenue: 27800,
    refunds: -420,
    marginPct: 0.288,
  },
  {
    id: "o4",
    region: "Midwest",
    channel: "Retail",
    revenue: 33400,
    refunds: -900,
    marginPct: 0.171,
  },
  {
    id: "o5",
    region: "Midwest",
    channel: "Wholesale",
    revenue: 62700,
    refunds: -2650,
    marginPct: 0.139,
  },
  {
    id: "o6",
    region: "Midwest",
    channel: "Online",
    revenue: 18900,
    refunds: -310,
    marginPct: 0.254,
  },
  {
    id: "o7",
    region: "South",
    channel: "Retail",
    revenue: 52600,
    refunds: -1780,
    marginPct: 0.198,
  },
  {
    id: "o8",
    region: "South",
    channel: "Wholesale",
    revenue: 74300,
    refunds: -2990,
    marginPct: 0.152,
  },
  {
    id: "o9",
    region: "South",
    channel: "Online",
    revenue: 24100,
    refunds: -360,
    marginPct: 0.301,
  },
  {
    id: "o10",
    region: "West",
    channel: "Retail",
    revenue: 61200,
    refunds: -2040,
    marginPct: 0.183,
  },
  {
    id: "o11",
    region: "West",
    channel: "Wholesale",
    revenue: 88900,
    refunds: -3550,
    marginPct: 0.147,
  },
  {
    id: "o12",
    region: "West",
    channel: "Online",
    revenue: 31700,
    refunds: -480,
    marginPct: 0.275,
  },
];
```


`locale` is a prop on `<Pretable>`, `<PretableSurface>`, and
`<LabeledGridSurface>` alike; the grid above passes `"en-US"`. The formatter it
compiles for a column is a single instance, and the same one serves the data
cells, the inherited aggregates on those region rows, and the built-in
clipboard serializer.

## Native options, not format codes

`numberFormat` accepts `Intl.NumberFormatOptions`, so the grid uses the same
option names and locale data as the rest of your app — there is no separate
format-code DSL to learn or translate. The grid above declares it exactly this
way:

```tsx
import { numberFormats, type PretableColumn } from "@pretable/react";

const columns: PretableColumn<Order>[] = [
  {
    id: "revenue",
    header: "Revenue",
    type: "number",
    aggregate: "sum",
    numberFormat: numberFormats.money({ currency: "USD" }),
  },
  {
    id: "refunds",
    header: "Refunds",
    type: "number",
    aggregate: "sum",
    numberFormat: numberFormats.accounting({ currency: "USD" }),
  },
];
```

Keep the options object stable when possible. Pretable compiles one native
formatter per configured column and reuses it until that options object or the
surface `locale` changes.

`numberFormat` is a column option on the typed
[`createColumnHelper`](/docs/grid/api-reference#typed-columns) path too, so the
same presentation is available without hand-declaring `PretableColumn`:

```ts
import { createColumnHelper, numberFormats } from "@pretable/core";

const column = createColumnHelper<Order>();
const columns = [
  column.accessor("revenue", {
    type: "number",
    header: "Revenue",
    aggregate: "sum",
    numberFormat: numberFormats.money({ currency: "USD" }),
  }),
] as const;
```

## Decimal formatting is opt-in

`type: "number"` alone never formats a value. It selects numeric filtering,
editing, clipboard hints, and the default end alignment, but display remains
the raw value until you add `numberFormat` or `format`. The Margin column in
the grid above is a raw `Intl.NumberFormatOptions` object rather than one of
the currency presets — no preset is required to opt in:

```tsx
const columns: PretableColumn<Order>[] = [
  {
    id: "marginPct",
    header: "Margin",
    type: "number",
    aggregate: "avg",
    numberFormat: {
      style: "percent",
      maximumFractionDigits: 1,
    },
  },
];
```

Pretable does not force two decimal places. Fraction digits, significant
digits, grouping, notation, sign display, units, and percentages all follow
the native options you provide and the locale's `Intl` defaults.

## Money and accounting presets

`numberFormats.money({ currency })` and
`numberFormats.accounting({ currency })` produce strict native
`Intl.NumberFormatOptions`. Both force `style: "currency"`; money forces
`currencySign: "standard"`, and accounting forces
`currencySign: "accounting"`. The exported
`PretableCurrencyFormatOptions` type requires `currency` and excludes
consumer-supplied `style` and `currencySign` while leaving the rest of the
native options available.

The helpers do not impose a two-decimal policy. Native currency rules still
decide the defaults unless you explicitly provide fraction-digit options.
They are small option builders, not formatter instances, and both
`numberFormats` and `PretableCurrencyFormatOptions` are exported from
`@pretable/core` and `@pretable/react`.

## Locale and server rendering

Treat `locale` as app presentation context: pass the same value your dates,
messages, and other localized UI use. It is forwarded unchanged to
`Intl.NumberFormat` and also reaches the public `SerializeRangesArgs` passed to
`onCopy`. Calling `serializeRanges` directly accepts the same optional
`locale`.

If `locale` is omitted, the JavaScript runtime's default locale is used. An
explicit locale is required for deterministic server-rendered hydration;
otherwise the server and browser can choose different defaults and produce
different initial text. Keep the explicit locale identical across the server
render and hydration.

## Aggregates inherit number formatting

A numeric group aggregate inherits its column's `numberFormat` for final
display and built-in clipboard output. In the grid above, grouping by region
folds Revenue, Refunds, and Margin into per-region aggregate rows — none of
the three declares a `formatAggregate` callback, yet each aggregate reads in
the same money, accounting, or percent presentation as its data cells,
because inheriting `numberFormat` is what happens by default.

`formatAggregate` still has the highest aggregate precedence. When present, it
wins over the inherited `numberFormat`; the ordinary data-cell `format`
callback is never called for a group aggregate because a group row has no data
row to pass it.

## Display, clipboard, and raw-value boundaries

The built-in display and clipboard channels agree whenever a column supplies a
`format` callback or `numberFormat` configuration. Group rows likewise use
`formatAggregate` first and inherited `numberFormat` second in both channels.

Without either configuration, the fallback remains channel-specific. Display
uses the renderer's ordinary text coercion, while clipboard serialization keeps
its existing TSV/HTML-safe coercion for dates, objects, and other values.

Formatting never changes the row model. Sorting, filtering, grouping,
aggregation, editing, validation, paste, and transactions continue to receive
raw values and never receive formatted strings.

## Custom values and callbacks

Native number formatting accepts JavaScript `number` and `bigint` values.
Numeric strings and Decimal-like objects are deliberately not coerced; use a
column `format` callback when those are your domain values.

For data cells, `format` outranks `numberFormat`. A column `render` callback
then receives the winning string as `formattedValue`, so custom JSX does not
need to repeat the formatting work. On `<LabeledGridSurface>`, `column.render`
wins first; otherwise its label/value wrapper runs, and the wrapper's
`formatValue` receives both raw `value` and the already resolved
`formattedValue`.

For group aggregates, `formatAggregate` outranks inherited `numberFormat`.
These two callback rules let domain-specific values or aggregate labels replace
native formatting without changing any raw-data behavior.

## Deliberate limits

Native number formatting is intentionally a presentation primitive. Pretable
does not add dash-for-zero behavior, numeric alignment beyond the existing
column `align` option, an export subsystem, or a spreadsheet-style format-code
DSL. Use `format` or `formatAggregate` for domain-specific text, CSS or `align`
for layout, and the clipboard hooks or your own export pipeline for other
channels.
