ICU module
@messagevisor/module-icu adds ICU message syntax support to Messagevisor. It is the module you want for interpolation, plurals, select logic, named number/date/time formats, inline ICU skeletons, and rich text tags.
Install#
$ npm install @messagevisor/module-icuMost applications also install the SDK package:
$ npm install @messagevisor/sdkReact applications usually install:
$ npm install @messagevisor/sdk @messagevisor/react @messagevisor/module-icuProject setup#
Register the module in messagevisor.config.js so CLI evaluation, examples, tests, catalog output, and generated workflow checks all use the same behavior:
const { createICUModule } = require("@messagevisor/module-icu");module.exports = { modules: [createICUModule()],};If you intentionally want inline ICU skeleton styles such as {amount, number, ::currency/GBP}, also enable icuSkeleton for linting:
const { createICUModule } = require("@messagevisor/module-icu");module.exports = { modules: [createICUModule()], icuSkeleton: true,};Prefer named locale formats for shared product copy. Inline skeletons are best for one-off strings where a reusable preset would be noise.
SDK setup#
The SDK does not interpret ICU syntax by default. Add the module to the runtime instance:
import { createMessagevisor } from "@messagevisor/sdk";import { createICUModule } from "@messagevisor/module-icu";import datafile from "./datafiles/messagevisor-web-en-US.json";const m = createMessagevisor({ datafile, modules: [createICUModule()],});m.translate("cart.items", { count: 3 });m.formatMessage("Hello {name}", { name: "Ada" });Without the module, Hello {name} stays Hello {name}. With the module, it becomes Hello Ada.
React setup#
React uses the same SDK instance. Configure ICU on the SDK, then pass that instance to MessagevisorProvider:
import { createMessagevisor } from "@messagevisor/sdk";import { createICUModule } from "@messagevisor/module-icu";import { MessagevisorProvider, useTranslation } from "@messagevisor/react";import datafile from "./datafiles/messagevisor-web-en-US.json";const m = createMessagevisor({ datafile, modules: [createICUModule({ ignoreTags: false })],});export function AppRoot() { return ( <MessagevisorProvider instance={m} defaultRichTextElements={{ strong: (chunks) => <strong>{chunks}</strong>, link: (chunks) => <a href="/terms">{chunks}</a>, }} > <App /> </MessagevisorProvider> );}function App() { const title = useTranslation("dashboard.welcome", { name: "Ada" }); return <h1>{title}</h1>;}Use ignoreTags: false when messages contain rich text tags such as <link>terms</link>. Rich tag callbacks and non-string renderable output are JavaScript-enhanced capabilities. Portable SDKs in other languages may support the string ICU subset without supporting rich callback output.
Supported ICU shapes#
| ICU type | Example | Messagevisor support |
|---|---|---|
| Interpolation | {name} | Values object passed to SDK, tests, examples, or CLI |
plural | {count, plural, one {...} other {...}} | Locale-aware cardinal plural rules |
select | {gender, select, male {...} other {...}} | String equality branches |
selectordinal | {rank, selectordinal, one {...} other {...}} | Locale-aware ordinal plural rules |
number | {amount, number, money} | Uses formats.number.<style> |
date | {startsAt, date, long} | Uses formats.date.<style> |
time | {startsAt, time, short} | Uses formats.time.<style> |
| Rich text tags | <link>terms</link> | Requires ignoreTags: false and tag handlers |
| Inline skeletons | {amount, number, ::currency/USD} | Supported by intl-messageformat; enable icuSkeleton for linting |
Messagevisor format families such as relative and dateTimeRange are available through SDK formatter helpers, but ICU message strings use the number, date, and time style families.
Named format styles#
Named ICU styles reference locale format presets. Define the style in a locale:
description: English (United States)formats: number: money: style: currency currency: USD currencyDisplay: symbol compactShort: notation: compact compactDisplay: short distanceKm: style: unit unit: kilometer unitDisplay: short date: long: year: numeric month: long day: numeric time: short: hour: numeric minute: 2-digitThen reference those names in messages:
translations: en-US: "Total: {amount, number, money}. Delivery: {distance, number, distanceKm}."Named formats keep product copy stable while letting locale owners tune the display behavior in one place.
Number examples#
Decimal#
translations: en: "Score: {score, number, decimal}"formats: number: decimal: maximumFractionDigits: 2Currency#
translations: en-US: "Total: {amount, number, money}"formats: number: money: style: currency currencyDisplay: symbolIf currency is omitted, Messagevisor uses the SDK instance currency at runtime. Define currency in the preset when that style should always use a fixed currency. Per-call options still win over both:
m.translate("billing.total", { amount: 12 }, { currency: "EUR" });Compact and scientific notation#
formats: number: compactShort: notation: compact compactDisplay: short scientific: notation: scientifictranslations: en: "Audience: {count, number, compactShort}. Scientific: {count, number, scientific}."Unit#
formats: number: distanceKm: style: unit unit: kilometer unitDisplay: shorttranslations: en: "Distance: {distance, number, distanceKm}"Date and time examples#
formats: date: long: year: numeric month: long day: numeric time: event: hour: numeric minute: 2-digittranslations: en: "Starts on {startsAt, date, long} at {startsAt, time, event}"If timeZone is omitted, Messagevisor uses the SDK instance time zone or the runtime default. Define timeZone in the preset when the style should always render in a fixed zone. Values are passed to Intl.DateTimeFormat, so use identifiers that your JavaScript runtime supports:
m.translate( "event.startsAt", { startsAt: new Date("2026-05-06T10:00:00Z") }, { timeZone: "Europe/Amsterdam" });Plural#
translations: en: "{count, plural, =0 {No items} one {# item} other {# items}}" nl: "{count, plural, =0 {Geen items} one {# item} other {# items}}"Plural categories are locale-aware. English commonly uses one and other; other locales may use zero, two, few, or many. The # placeholder renders the numeric value.
Select#
translations: en: "{role, select, admin {Admin joined} owner {Owner joined} other {Member joined}}"select matches exact string values. Always include an other branch.
Selectordinal#
translations: en: "You are {rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} in line"selectordinal uses ordinal rules, not cardinal plural rules.
Nesting#
You can nest plurals inside select branches:
translations: en: > {status, select, trial {{days, plural, one {# day left} other {# days left}}} paid {Your plan is active} other {Choose a plan} }Keep nested ICU readable. For complex messages, add examples and tests so reviewers can see each branch.
Rich text#
Rich text tags are useful when the same translation needs to render links, strong text, or other React elements:
translations: en: "Read the <link>terms</link> for <strong>{product}</strong>."JavaScript usage:
const m = createMessagevisor({ datafile, modules: [createICUModule({ ignoreTags: false })],});m.translate("legal.terms", { product: "Messagevisor", link: (chunks) => `[${chunks.join("")}]`, strong: (chunks) => chunks.join("").toUpperCase(),});React usage is usually cleaner through defaultRichTextElements:
<MessagevisorProvider instance={m} defaultRichTextElements={{ link: (chunks) => <a href="/terms">{chunks}</a>, strong: (chunks) => <strong>{chunks}</strong>, }}> <LegalCopy /></MessagevisorProvider>Per-call module options#
Each module can read options by its module name. The ICU module defaults to the name icu.
m.formatMessage( "Read <link>terms</link>.", { link: (chunks) => `[${chunks.join("")}]` }, { moduleOptions: { icu: { ignoreTags: true, }, }, });Use this sparingly. It is best for one rendering path that needs to treat tags differently.
Testing ICU messages#
Message tests are the safest way to lock down ICU behavior:
message: cart.itemsassertions: - description: Empty cart locale: en target: web values: count: 0 expectedTranslation: No items - description: Multiple items locale: en target: web values: count: 3 expectedTranslation: 3 itemsLocale examples are useful for demonstrating reusable formats. They can be checked from the terminal with npx messagevisor examples --onlyLocales and reviewed visually in the generated Catalog:
examples: - description: Compact audience count rawMessage: "Audience: {count, number, compactShort}" values: count: 1200CLI evaluation#
Use evaluate for fast debugging:
$ npx messagevisor evaluate --rawMessage="{count, plural, one {# item} other {# items}}" --locale=en --values='{"count":3}'$ npx messagevisor evaluate --message=cart.items --locale=en --values='{"count":5}'For sets-based projects, include --set=<set>.
Edge cases and behavior notes#
ICU behavior depends on locale#
Plural and ordinal categories come from the active locale. The same message can evaluate differently across locales.
Named formats must exist#
If a message references {amount, number, money}, the active locale's resolved formats.number.money must exist, either directly or through inheritFormatsFrom.
Module registration must match across environments#
Project config, SDK setup, React setup, tests, examples, and catalog generation should all register ICU consistently. Otherwise a message can pass review but render literally in an application.
Relative time and date-time range are SDK formatter helpers#
Use m.formatRelativeTime() and m.formatDateTimeRange() for those families. They are part of Messagevisor formats, but not named ICU styles in message strings.

