Plurals and select forms are the cases where one source string isn't enough — the translation depends on a number or a category. @lingo.dev/react exposes two friendly helpers (l.plural and l.select) that compile to ICU MessageFormat under the hood, so translators see the standard syntax and runtime stays the same.
Plurals#
l.plural(count, forms, { context }) picks the right form based on count and the locale's CLDR plural rules.
const l = useLingo();
l.plural(items.length, {
one: "1 item",
other: "{count} items",
}, { context: "Cart summary" });
// → "1 item" (en, count=1) / "5 items" (en, count=5)
// → "1 Eintrag" / "5 Einträge" (de, after translation)Forms by locale#
The forms map accepts every CLDR plural category — zero, one, two, few, many, other. Locales pick what they need:
- English uses
one+other(1 vs everything else) - Russian uses
one+few+many+other(1; 2-4; 5-20; 21, 31, ...) - Arabic uses all six
- Japanese uses only
other(no plural distinction)
You only need to provide the forms the source locale uses — translators add the rest per target locale.
{count} is interpolated automatically inside any plural form. You don't pass it via values — it comes from the first argument.
Combining with other placeholders#
For sentences with both a count and other variables, write the variables into the form strings; they'll be passed through to ICU.
l.plural(notifications.length, {
one: "1 message from {sender}",
other: "{count} messages from {sender}",
}, { context: "Inbox header" });Then pass the values when you call — but wait, l.plural's signature only has { context }. For mixed cases, use l.text directly with ICU plural syntax:
l.text(`{count, plural, one {1 message from {sender}} other {# messages from {sender}}}`, {
values: { count: notifications.length, sender: user.name },
context: "Inbox header",
});The # token is replaced with the count value verbatim — useful when you want it without the curly-brace interpolation form.
Select#
l.select(value, forms, { context }) picks a form based on a string key (gender, role, content type — anything categorical).
l.select(user.gender, {
male: "He uploaded a photo",
female: "She uploaded a photo",
other: "They uploaded a photo",
}, { context: "Activity feed" });other is required as a fallback. The match is exact — there's no fuzzy or case-insensitive matching.
Selectordinal#
For ordinal numbers (1st, 2nd, 3rd) use ICU selectordinal directly via l.text:
l.text(`You finished in {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place`, {
values: { place: rank },
context: "Leaderboard",
});
// → "You finished in 1st place" / "2nd" / "3rd" / "4th, 5th, ..."What this compiles to#
Both l.plural and l.select build an ICU MessageFormat string and pass it to l.text. The compiled form is what gets extracted by lingo extract and stored in your locale files — translators edit ICU syntax directly, not the JS object literal.
Example: l.plural(n, { one: "1 item", other: "{count} items" }, { context: "Cart" }) extracts as:
{count, plural, one {1 item} other {{count} items}}This means translators can adapt the categories per locale, including ones the source doesn't have. Russian becomes {count, plural, one {...} few {...} many {...} other {...}} without any code change.
When not to use these#
- A simple "1 or many" boolean. Two
l.textcalls under anifare fine and easier for translators to spot. - Programmatic enum that's not user-facing. Plural / select are for translation of categorical messages, not for routing app logic.
Where to next#
- useLingo — base
l.textandl.richsemantics. - Formatting — number, currency, date, list formatting via native Intl.
