# Formatting & scale (/docs/formatting)

Two orthogonal decisions live here: **how numbers are written** (`format` + `locale`) and **what range they're drawn
against** (`domain`). Both are part of the one grammar — the same prop name means the same thing on every chart — and
both are documented per chart, but this page is the whole story in one place, since it's easy to assume a default you
didn't actually get.

## One formatter, three places

A chart shows numbers in three spots: the **direct labels** it draws, the **accessible summary** a screen reader
announces, and — on an interactive entry — the **readout** that follows the pointer. `format` feeds _all three_ from a
single definition — so a currency chart's spoken summary says "$1,240", not "1240". You never format the same number
twice.

`format` takes either **`Intl.NumberFormatOptions`** or a **plain function** `(n) => string`. Formatters are cached
(`Intl.NumberFormat` construction is expensive), keyed by locale plus options, so hundreds of instances sharing one
format shape build one formatter between them — never build your own `new Intl.NumberFormat` in a component. The cache
is deliberately small: it holds 64 formatters and empties completely when it fills, on the reasoning that one app uses a
handful of locale × option shapes, and a bounded cache that occasionally rebuilds beats one that grows forever. Need the
same formatting _outside_ a chart (a matching KPI label, a table cell)? Import the exported `makeFormatter` helper so
your prose numbers line up with the chart's exactly, from the same cache:

```tsx
import { makeFormatter } from "@microcharts/react";

const fmt = makeFormatter({ style: "currency", currency: "USD" }, "en-US");
fmt(42); // "$42.00"
```

The arguments are positional — `makeFormatter(format, locale, defaults?)`. The third argument is the fallback used when
`format` is omitted; it's how a chart supplies its own default (see `Delta` below).

```tsx
<Sparkline data={revenue} label="last" format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} />
```

## Percent, compact, decimals

Anything `Intl.NumberFormat` supports works — percent, compact notation, fixed decimals, sign display. `Delta` even
defaults to a locale-aware percent, so a raw ratio reads as a change with no config:

```tsx
<Delta value={0.128} />                                     // → +12.8%
<Delta value={4200} format={{ notation: "compact" }} />     // → +4.2K
<Delta value={4200} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} />
```

## A custom function

When `Intl` can't express the unit — milliseconds, "×" multipliers, a domain-specific symbol — pass a function. It
receives the number and returns the exact string to render. The value is snapped to 12 significant digits before it
reaches your function, so a number a chart derived internally (`value - target`) arrives as `-3.6`, not
`-3.5999999999999943` — you write for clean data, not for IEEE arithmetic.

```tsx
<Sparkline data={latency} label="last" format={(n) => `${Math.round(n)} ms\
```

## The interactive readout

Import a chart from its `/interactive` subpath and it gains a readout: a small chip that follows the pointer, or the
focused datum when you rove with the keyboard. It uses the same `format` and `locale` as everything else, so a currency
chart's hover value matches its label and its summary exactly.

**It is deliberately terse.** The chip names the datum and its value — a Funnel stage reads `Checkout 62% (1,240)`, a
Slope line reads `Brazil: 44 → 28` — and nothing else. It does not repeat the axis names, the row label already printed
beside it, or the grid position the cell already occupies. These are word-sized charts; a chip wider than the chart it
annotates is worse than no chip.

The fuller sentence still exists. Everything the chip leaves out — the comparison, the quadrant name, the running total,
the per-cycle breakdown — is announced in the chart's live region, which is what a screen reader reads. So nothing is
lost by the chip being short; it is a different audience, not less information.

```tsx
import { Sparkline } from "@microcharts/react/sparkline/interactive";

// the readout, the endpoint label, and the spoken summary all read "$1,600"
<Sparkline data={revenue} label="last" format={{ style: "currency", currency: "USD" }} />;
```

The chip is capped at `max(100%, 14em)` wide and ellipsizes past that, so a long formatted value cannot paint across the
page. If you see an ellipsis, the value is too long for the chart — shorten it with `format` (`notation: "compact"` is
usually the answer) rather than widening the chart.

Its prose comes from `strings`, like every other word the library renders, so a localized bundle localizes the readout
too. Its colors come from `--mc-surface`, `--mc-surface-ink` and `--mc-surface-edge`; see
[Theming](/docs/theming#the-readout-surface).

**Put the value somewhere else.** That same terse string is handed to you: every `onActive` / `onSelect` callback
receives it as `datum.formatted`, formatted with the chart's own `format` and `locale`. So you can suppress the chip and
render the value in your own layout — the pattern behind a KPI card whose big number tracks a sparkline you hover:

```tsx
const [reading, setReading] = useState("$1,600");

// readout={false} keeps the crosshair, drops the chip; the value lands in your <output>
<output>{reading}</output>;
<Sparkline
  data={revenue}
  format={{ style: "currency", currency: "USD" }}
  readout={false}
  onActive={(d) => d && setReading(d.formatted!)}
/>;
```

`readout={false}` hides only the chip — hover, keyboard roving, touch scrub, the live-region announcements, and both
callbacks all keep working. Leave `readout` at its default (`true`) and you get the chip and the callback both.

## Locale: separators, not language

`locale` is a BCP-47 tag (`"de-DE"`, `"ja-JP"`, or an array of fallbacks). It controls how numbers and dates are
_written_ — the thousands separator, decimal mark, currency placement — matching the reader's conventions.

```tsx
<Sparkline data={revenue} label="last" locale="en-US" /> // 1,600
<Sparkline data={revenue} label="last" locale="de-DE" /> // 1.600
```

> `locale` sets number/date **formatting**. It does not translate the summary _sentence_ ("Trending up 200%…") — that's the `strings` contract on [Accessibility](/docs/accessibility#internationalization-and-rtl). Pick both for a fully localized chart: `locale` for the figures, translated `strings` for the prose.

### Server rendering: pass a `locale` explicitly

Omitting `locale` means `Intl` resolves the _runtime's_ default — and on a server-rendered page that runtime is the
server. Node typically resolves to the host's locale while the visitor's browser resolves to theirs, so the same chart
can render `1,600` into the HTML and `1.600` after hydration. React reports that as a hydration mismatch, and the
mismatched text is not patched up.

This is not specific to microcharts — it is how `Intl` defaults work — but charts hit it more often than most UI,
because the number reaches three places at once: the label, the accessible summary, and the readout.

The fix is to make the choice explicit rather than ambient:

```tsx
// Deterministic: the server and the browser format identically.
<Sparkline data={revenue} label="last" locale="en-US" />
```

Pass the locale your page has already resolved (from the request, a cookie, or your i18n provider) and use the same one
on both sides. If a chart is client-only, the ambient default is fine.

## Scale: the `domain` decision

`domain` sets the value range the geometry is drawn against — the vertical frame. Omit it and the chart **auto-fits to
its own data**. Pass `[min, max]` to pin a fixed frame instead — so a 40–60% reading looks like 40–60% of a 0–100 scale,
not a full-height line.

```tsx
<Sparkline data={cpu} />                  // auto-fit — fills the height
<Sparkline data={cpu} domain={[0, 100]} /> // fixed 0–100% frame
```

Auto-fit is not the same everywhere. Charts whose marks read as magnitude from a baseline — `Sparkbar`, `MiniBar`,
`PairedBars`, and `Sparkline` once you turn on `fill` — anchor the auto-fitted domain at **zero**, because a bar cut off
above zero lies about its own length. Line charts without a fill auto-fit to the data alone. Either way, a completely
flat series is padded so it still has a band to sit in rather than collapsing to a degenerate scale.

This is an **honesty decision**, not cosmetics: auto-fit maximizes detail for a single series, but a fixed domain is
what makes two charts comparable. To share one scale across several charts at once, use `SparkGroup` — it reads every
child's `data`, computes one union domain, and injects it into any child that didn't set its own (`zero` anchors that
shared domain at zero). See [Composition](/docs/composition#small-multiples-one-shared-scale).

## Dates and times

On calendar and time charts, `format` still means what it means everywhere else: it formats the **numbers**. On
`EventTimeline` that's a share, defaulting to a locale-aware percent. On `CalendarStrip` it's the day's value in the
hover/focus readout — so it lives on the interactive entry only. The static `CalendarStrip` summary counts days —
`Active 3 of 28 days over 4 weeks.` — and a count of days is not a number `format` gets to restyle.

Date and time _strings_ are a separate prop, **`dateFormat`**, taking `Intl.DateTimeFormatOptions` or a
`(date) => string` function. It lives on the interactive entries (`@microcharts/react/calendar-strip/interactive`,
`@microcharts/react/event-timeline/interactive`), which are the entries that render a spoken or hovered date at all —
`locale` applies to it the same way.

```tsx
import { CalendarStrip } from "@microcharts/react/calendar-strip/interactive";

<CalendarStrip data={days} dateFormat={{ month: "short", day: "numeric" }} locale="de-DE" />;
```

When you pass options (rather than a function), the formatter defaults to `timeZone: "UTC"`, so the same input renders
identically in any host timezone — a chart never shifts a day because the viewer is in a different region. Your options
spread after that default, so `{ timeZone: "America/New_York" }` overrides it deliberately. A `(date) => string`
function bypasses the default entirely and owns its own timezone handling.
