On this page

Formatting and localization

{{revenue | currency:"USD"}}
{{completion | percent}}
{{createdAt | date:"medium"}}

A formatter takes the value and returns a string, and that string is inserted as text. It's never re-parsed as Markdown, so adding a formatter doesn't open up any new way to inject content.

The six built-in formatters#

FormatterArgumentExampleValue it accepts
numberfraction digits, 0 to 20{{count | number}}a finite number or a bigint
currencyISO 4217 code, required{{total | currency:"USD"}}a finite number or a bigint
percentfraction digits, 0 to 20{{rate | percent:1}}a finite number, where 0.42 is 42%
datefull, long, medium or short{{signedAt | date:"long"}}a Date, an epoch number or an ISO string
timefull, long, medium or short{{startsAt | time:"short"}}a Date, an epoch number or an ISO string
datetimefull, long, medium or short{{updatedAt | datetime}}a Date, an epoch number or an ISO string

Date styles default to medium. All six use the platform Intl APIs, so the output follows the same CLDR data as the rest of your application.

A value of the wrong type gives you a diagnostic (it doesn't throw). For example currency applied to a Date produces VARIABLE_FORMATTER_VALUE_TYPE and ok: false.

Currency is always explicit#

A locale doesn't tell you which currency an amount is in. fr-FR formats euros and Swiss francs equally well, and if we guessed we'd sometimes print the wrong symbol on an invoice.

{{total | currency:"USD"}}   ✓
{{total | currency}}         ✗ VARIABLE_FORMATTER_ARGUMENT_REQUIRED

The code has to be three letters. The locale controls symbol placement, digit grouping and the decimal separator. The currency is whatever code you pass.

Locale and time zone#

Both are options of variables().

variables({ data, locale: 'fr-FR', timeZone: 'America/New_York' })

timeZone defaults to UTC, so the same data gives you the same document on a laptop and on a build server. locale defaults to en-US.

The same source, formatted for three locales:

One source, three locales
Authored sourcenever changes
## {{project.name}}

Budget: {{budget | currency:"EUR"}}

Completion: {{completion | percent}}

Reviewed {{reviewedAt | date:"long"}}
Resolved for en-USchanges

Harbour migration

Budget: €1,284,500.50

Completion: 62%

Reviewed February 17, 2026

The data and the source are the same in all three. The only thing that changes is the locale option.

Custom formatters#

Pass them to variables(), or put them in an extension of your own so every document in the application gets them (see Extensions).

variables({
  data,
  formatters: {
    accountStatus(value) {
      return formatAccountStatus(value)
    },
  },
})

A formatter gets the value plus a context with argument, locale, timeZone and path. The path is there for diagnostics. The value itself never shows up in a message the kit produces.

import type { VariableFormatter } from '@react-markdown-kit/variables'


const rounded: VariableFormatter = (value, { locale, argument }) => {
  if (typeof value !== 'number') throw new Error('rounded needs a number')
  return new Intl.NumberFormat(locale, {
    maximumFractionDigits: Number(argument ?? 0),
  }).format(value)
}

Formatters are layered in this order: built-ins, then formatters from other extensions in the same preset, then the formatters option. The last one wins, so an application can replace date with its own house style without forking anything. builtinFormatters is exported for reference:

import { builtinFormatters } from '@react-markdown-kit/variables'


Object.keys(builtinFormatters)
// ['number', 'currency', 'percent', 'date', 'time', 'datetime']

If a formatter throws, you get VARIABLE_FORMATTER_FAILED. If you want a more specific diagnostic, throw VariableFormatterError with your own code.

Localized source#

There are three separate concerns here. The kit doesn't handle translation for any of them.

ConcernWho owns it
Document languageThe authored source you pick for the reader's locale
Runtime formattingThe locale and timeZone options
Localized assetsApplication data, such as a per-locale image URL

Keep one authored source per language and pick the right one before compiling. The plugin formats with whatever locale you pass, regardless of which source it's applied to. The kit doesn't do any machine translation.

const source = SOURCES[reader.locale] ?? SOURCES['en-US']


<Markdown extensions={[variables({ data, locale: reader.locale })]}>{source}</Markdown>

Next#

The overview, from placeholder syntax to schemas: Markdown variables.

Last updated on