Skip to contentSkip to footer

If you want to be kept up to date with new articles, CSS resources and tools, join our newsletter.

Chances are you've used Moment.js, date-fns, Luxon, or numeral.js at some point. Developers have relied on these libraries for years to format dates, numbers, and currencies. Those are all very useful libraries, but they also come with a cost: they add kilobytes to your download size and they require client-side code parsing:

LibrarySize
Moment.js295 kB
date-fns77 kB
luxon.js82 kB
numeral.js11 kB

The Intl API is baseline widely available (except for Intl.DurationFormat. It works in all current evergreen browsers, but hasn't been around long enough to qualify for "widely available".) and can handle almost all of your formatting requirements directly in the browser, with zero kB of download and no JS bundle parsing required. It also knows your users' locale preferences so you can format dates and numbers in a way that feels natural to them without any extra work.

Prefer video?

This article is a written version of a talk by Kilian Valkhof, Polypane's creator. You can watch the video here:

Interested in having this talk at your conference or meetup? Get in touch!

Why you need an internationalization API

You might be thinking: "My site is only in one language. Why would I need an internationalization API?"

That's the most common reason developers skip over Intl. But Intl is less about translating your UI and more about formatting data correctly for your users.

Consider a date like 09/12/2025. If you're from the US, you'd read it as September 12th. Almost anywhere else in the world, you'd read it as December 9th. That's quite a big difference, and if a carelessly (un)formatted date leads to someone missing an appointment or even just confusion, that reflects very poorly on your site.

The Intl API formats dates, numbers, currencies, lists, and text in a way that fits the user's locale. Even if your site is just in English, users from different English speaking countries still have different formatting conventions. Intl handles all of that for you, so you don't have to worry about it.

What Intl gives you

  • 0 kB added to your bundle: it's built into the browser
  • Baseline widely available: works in every major browser
  • Locale-aware: knows not just languages but country-specific conventions
  • Fast: native implementations are significantly faster than userland libraries

The APIs we'll cover:

  • Intl.DateTimeFormat: format dates and times
  • Intl.RelativeTimeFormat: "yesterday", "in 3 days"
  • Intl.DurationFormat: "2 hours, 5 minutes and 30 seconds"
  • Intl.NumberFormat: format numbers, currencies, and units
  • Intl.ListFormat: "Apples, Oranges, and Bananas"
  • Intl.PluralRules: figure out plural forms correctly
  • Intl.Segmenter: break text into words, sentences, or graphemes
  • Intl.Collator: sort strings in a locale-aware way

What Intl doesn't do

We'll get into plenty of examples later on in this article but a common misunderstanding, or maybe wish, is that Intl also handles data wrangling. Unfortunately, Intl is a formatting API, not a calculation or measurement API. It just takes objects (like dates, numbers and arrays) and turns them into strings.

For example if you want to format a relative time compared to now, like to show when a blog post was released compared to the current time, you need to calculate the difference yourself and then pass that to Intl.RelativeTimeFormat. We have an example of how to do that in the section on RelativeTimeFormat.

Later on in the article you'll also see that it can format numbers with units (like degrees Celsius or miles) but it has no conception of what those units mean. So while it knows to put a °C after a number when you ask for Celsius, it doesn't know how to convert from Fahrenheit to Celsius. It's just takes a number and returns a string with an unit in it.

How the Intl API works

All Intl functions have roughly the same shape:

  1. Pick a locale.
  2. Pick some options.
  3. Create a formatter.
  4. Reuse that formatter with your data.

Here's the general setup with Intl.DateTimeFormat:

const locale = 'en-US';

const options = {
  dateStyle: 'full',
  timeStyle: 'short',
};

const formatter = new Intl.DateTimeFormat(locale, options);

Then, once you have the formatter, you pass it the value you want to format:

const publishedAt = new Date('2026-04-07T16:45:00Z');

formatter.format(publishedAt);
// → "Tuesday, April 7, 2026 at 4:45 PM"

formatter.format(new Date('2026-06-01T09:00:00Z'));
// → "Monday, June 1, 2026 at 9:00 AM"

That same pattern shows up throughout Intl:

new Intl.DateTimeFormat(locale, options).format(date);
new Intl.NumberFormat(locale, options).format(number);
new Intl.ListFormat(locale, options).format(items);

new Intl.PluralRules(locale, options).select(number);
new Intl.Collator(locale, options).compare(a, b);

The constructor arguments are consistent too:

  1. A locale string such as 'en-US', 'nl-NL', or 'fr-FR'. If you omit it, the browser uses the user's system locale. You can also pass undefined to use the browser's default locale explicitly, which you need if you also want to set options.
  2. An options object that controls the output. The available options differ per formatter, but the setup pattern stays the same.

Creating the formatter is the expensive part, because that loads the locale data and sets up the internal structures. Calling .format(), .select(), or .compare() afterwards is cheap. That means you should create the formatter once and reuse it when you're handling a lot of values.

// Good: create once, reuse many times.
const formatter = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'short',
});

dates.map((date) => formatter.format(date));

// Less good: create a new formatter every time.
dates.map((date) => new Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).format(date));

That matters most with Intl.Collator, the API that lets you sort strings in a locale-aware way. Sorting can call the comparison function many times, so you definitely want to create the collator once and reuse it.

Setting the locale

For locales, you can use any valid BCP 47 language tag, such as 'en', 'en-US', 'nl-NL', or 'fr-FR'. You can also specify a list of locales, and the browser will pick the first one it supports. In practice, all major browsers support at least seven thousand locales, so you can be pretty confident that the browser will find a match for your users' preferences.

If you don't specify a locale, the formatter uses the browser's locale. You can set it to undefined to get the same effect. That means you can create a formatter that automatically adapts to the user's locale without any extra work. Alternatively you can explicitly ask for navigator.language or navigator.languages to get the user's language preferences and pass those to the formatter:

// Choose a specific locale
const formatter = new Intl.DateTimeFormat('nl-NL');
// Pass a list of locales, the browser picks the first one it supports
const formatter = new Intl.DateTimeFormat(['en-US', 'en-GB']);

// Use the browser's locale
const formatter = new Intl.DateTimeFormat();
const formatter = new Intl.DateTimeFormat(undefined);

// Use the browser's locale but specify options too
const formatter = new Intl.DateTimeFormat(navigator.language);

// Use the user's full language preference list
// (the browser picks the first one it supports)
const formatter = new Intl.DateTimeFormat(navigator.languages);

navigator.languages is an array like ['en-US', 'en', 'nl'], so works like a list of locales you can pass directly to the formatter.

What's the difference between a language and a locale? A locale is a language as spoken in a specific country. For example, both the US and the UK speak English, but they have different formatting conventions for dates and numbers. So you have en-US for American English and en-GB for British English. If you just specify en, the browser will pick a default locale for that language, which is usually the most common one.

Dates & Times

Let's start with the APIs that have the clearest naming: the ones for formatting dates and times.

Intl.DateTimeFormat

Intl.DateTimeFormat is the API for formatting dates and times.

const formatter = new Intl.DateTimeFormat(undefined, {
  dateStyle: "full",
  timeStyle: "short",
  timeZone: "UTC"
});
formatter.format(new Date("2026-04-07T16:45:00.000Z"));
Your locale
Tuesday, April 7, 2026 at 4:45 PM
English (US)
en-US
Tuesday, April 7, 2026 at 4:45 PM
Dutch
nl-NL
dinsdag 7 april 2026 om 16:45
French
fr-FR
mardi 7 avril 2026 à 16:45
Japanese
ja-JP
2026年4月7日火曜日 16:45
Arabic
ar-EG
الثلاثاء، ٧ أبريل ٢٠٢٦ في ٤:٤٥ م

At its simplest:

const formatter = new Intl.DateTimeFormat();
formatter.format(new Date());

// → "4/7/2026" (in en-US)

The default output is a bit terse. Most of the time you'll want to specify a style. The easiest way is with dateStyle and timeStyle:

const formatter = new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'full', // 'full' | 'long' | 'medium' | 'short'
  timeStyle: 'short', // 'full' | 'long' | 'medium' | 'short'
});

formatter.format(new Date());

// → "mercredi 8 avril 2026 à 09:19"

What's nice here is that you didn't have to know that "mercredi" is Wednesday in French, or that "avril" is April, or have arrays of days and months somewhere. You ask the browser for French, and the browser already knows.

When you need more control, you can specify exactly which parts of the date to show and how to format them:

const formatter = new Intl.DateTimeFormat(undefined, {
  weekday: "long",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  second: "2-digit",
  month: "long",
  year: "numeric"
});
formatter.format(new Date());
Your locale
Wednesday, April 8, 2026 at 1:32:17 PM

If you specify individual parts like this, only those parts will appear in the output. Mixing dateStyle/timeStyle with individual part options throws an error, you can use one or the other but not both.

You might have seen them in the interactive example, but there's a few more options that are worth mentioning:

const options = {
  hour12: true | false, // override the locale's 12/24-hour preference
  timeZone: 'UTC' | 'Europe/Amsterdam' | ..., // specify a time zone
  timeZoneName: 'short' | 'long', // "CET", "Central European Time", etc.
  dayPeriod: 'narrow' | 'short' | 'long', // "in the morning", "AM", etc.
};

formatToParts()

All Intl formatters that have a .format() method also have a .formatToParts() method. Instead of returning one big string, it returns an array of { type, value } objects. This makes it easy to wrap individual parts in markup for styling, such as making the day bold or adding a tooltip to the time zone name.

const formatter = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'long',
  timeStyle: 'short',
});

formatter.formatToParts(new Date('2026-04-07T16:45:00Z'));
// → [
//     { type: 'month',     value: 'April' },
//     { type: 'literal',   value: ' '     },
//     { type: 'day',       value: '7'     },
//     { type: 'literal',   value: ', '    },
//     { type: 'year',      value: '2026'  },
//     { type: 'literal',   value: ' at '  },
//     { type: 'hour',      value: '4'     },
//     { type: 'literal',   value: ':'     },
//     { type: 'minute',    value: '45'    },
//     { type: 'literal',   value: ' '     },
//     { type: 'dayPeriod', value: 'PM'    },
//   ]

Intl.RelativeTimeFormat

Intl.RelativeTimeFormat gives you human-friendly relative time strings like "yesterday" and "in 3 days".

const formatter = new Intl.RelativeTimeFormat(undefined, {
  numeric: "auto"
});
formatter.format(-3, "day");
Your locale
3 days ago
English (US)
en-US
3 days ago
Dutch
nl-NL
3 dagen geleden
French
fr-FR
il y a 3 jours
Japanese
ja-JP
3 日前
Arabic
ar-EG
قبل ٣ أيام

Unlike DateTimeFormat, it works with a number and a unit, not a Date:

const relative = new Intl.RelativeTimeFormat('nl-NL', {
  numeric: 'auto', // 'auto' | 'always'
});

relative.format(-3, 'month'); // → "3 maanden geleden"
relative.format(1, 'day'); // → "morgen"
relative.format(-1, 'day'); // → "gisteren"

With numeric: 'auto', the formatter uses words like "tomorrow" and "yesterday" when it can. With numeric: 'always', it always uses a number: "in 1 day", "1 day ago".

Units can be: 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', or 'year'.

Since you need to calculate the difference yourself, here's a helper pattern for calculating days between two dates:

const date = new Date('2026-05-01T00:00:00Z');
const diffInMs = date - new Date();
const diffInDays = Math.round(diffInMs / (1000 * 60 * 60 * 24));

const relative = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
relative.format(diffInDays, 'day');
// → "in 24 days" (or however far away May 1st is)

Dates are in milliseconds. So you can convert it by dividing it to the unit you want. Instead of a really big number, like 86400000 for the number of milliseconds in a day, your code is easier to read if you do it step by step: 1000 milliseconds in a second, 60 seconds in a minute, 60 minutes in an hour and then 24 hours in a day.

RelativeTimeFormat also has .formatToParts(), which breaks the output into 'integer', 'literal', and 'unit' pieces. This lets you wrap each part and style it separately.

const relative = new Intl.RelativeTimeFormat('en-US', { numeric: 'always' });

relative.formatToParts(-3, 'month');
// → [
//     { type: 'integer', value: '3', unit: 'month' },
//     { type: 'literal', value: ' months ago' },
//   ]

Intl.DurationFormat

Intl.DurationFormat formats time durations.

const formatter = new Intl.DurationFormat(undefined, {
  style: "long"
});
formatter.format({
  "hours": 2,
  "minutes": 45,
  "seconds": 30
});
Your locale
2 hours, 45 minutes, 30 seconds
English (US)
en-US
2 hours, 45 minutes, 30 seconds
Dutch
nl-NL
2 uur, 45 minuten en 30 seconden
French
fr-FR
2 heures, 45 minutes et 30 secondes
Japanese
ja-JP
2 時間 45 分 30 秒
Arabic
ar-EG
ساعتان، و٤٥ دقيقة، و٣٠ ثانية

It lets you choose between an output like 2:05:30 and one like "2 hours, 5 minutes, 30 seconds":

const duration = new Intl.DurationFormat('en-US', {
  style: 'long', // 'long' | 'short' | 'narrow' | 'digital'
});

duration.format({ hours: 2, minutes: 5, seconds: 30 });
// → "2 hours, 5 minutes, 30 seconds"

const clock = new Intl.DurationFormat('en-US', { style: 'digital' });
clock.format({ hours: 2, minutes: 5, seconds: 30 });
// → "2:05:30"

While the other date and time formatters use options to control which parts of the date to show, by default the DurationFormat just shows all the parts you give it in the formatter. So if you pass it hours, minutes and seconds, it will show all three. If you only pass it minutes and seconds, it will only show those two.

You can however force it to always show certain parts, even if they're zero by setting the *Display options:

const options = {
  yearsDisplay: 'auto' | 'always',
  monthsDisplay: 'auto' | 'always',
  daysDisplay: 'auto' | 'always',
  hoursDisplay: 'auto' | 'always',
  minutesDisplay: 'auto' | 'always',
  secondsDisplay: 'auto' | 'always',
  millisecondsDisplay: 'auto' | 'always',
  microsecondsDisplay: 'auto' | 'always',
  nanosecondsDisplay: 'auto' | 'always',
};

auto will only show the part if it's specified in the object, while always will always show it even if it's omitted.

The default option for all of these is almost always auto, except when you set style to digital, in which case the default for hours, minutes and seconds becomes always.

When you use digital, hours, minutes, and seconds are all shown by default even if they're zero, so you get 00:45:00 rather than just 45.

You can also format much finer durations, down to nanoseconds, which is useful for displaying performance measurements:

const perf = new Intl.DurationFormat('en-US', { style: 'narrow' });
perf.format({ milliseconds: 423, microseconds: 195 });
// → "423ms 195μs"

Numbers & Currencies

Intl.NumberFormat

Intl.NumberFormat formats plain numbers, currencies, and units.

const formatter = new Intl.NumberFormat(undefined, {
  style: "decimal",
  notation: "standard",
  useGrouping: "auto"
});
formatter.format(1234567.89);
Your locale
1,234,567.89
English (US)
en-US
1,234,567.89
Dutch
nl-NL
1.234.567,89
French
fr-FR
1 234 567,89
Japanese
ja-JP
1,234,567.89
Arabic
ar-EG
١٬٢٣٤٬٥٦٧٫٨٩

Those are all the same API, but they solve different problems, so it helps to keep them separate.

Plain numbers and percentages

For plain numbers, you normally use the default decimal style. That gives you locale-aware grouping and decimal separators without any extra work.

const formatter = new Intl.NumberFormat('nl-NL');

formatter.format(1000000); // → "1.000.000"
formatter.format(123456.789); // → "123.456,789"

Even for plain numbers, locale matters: Dutch uses . for thousands and , for decimals, while US English does the opposite.

Percentages use the same API with style: 'percent':

const pct = new Intl.NumberFormat('fr-FR', { style: 'percent' });
pct.format(0.1); // → "10 %"
pct.format(0.753); // → "75 %"

The general number formatter also supports different notations, which lets you set compact numbers like 1M :

const compact = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  compactDisplay: 'short',
});

compact.format(1007800); // → "1M"
compact.format(1534); // → "1.5K"

// For a longer version, use 'long' instead of 'short'

const compactLong = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  compactDisplay: 'long',
});

compactLong.format(1007800); // → "1 million"
compactLong.format(1534); // → "1.5 thousand"

Note how the compact notation rounds the number to fit the format. 1,007,800 becomes "1M", and 1,534 becomes "1.5K". If you want to control the rounding, you can use the fraction digit options or switch to significant digits.

You can control how many decimal places appear with the fraction digit options, or switch to significant digits for scientific or measurement data:

// Always show exactly two decimal places
const price = new Intl.NumberFormat('en-US', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

price.format(9.9); // → "9.90"
price.format(9.999); // → "10.00"

// Cap to three significant digits
const approx = new Intl.NumberFormat('en-US', {
  maximumSignificantDigits: 3,
});

approx.format(123456); // → "123,000"
approx.format(0.001234); // → "0.00123"

Currencies

A big feature in the number formatting API is formatting currencies, which even in its simplest form has quite a few differences between countries like if the symbol goes before or after the number, and getting them wrong in your UI will easily make your site look unprofessional or untrustworthy.

const formatter = new Intl.NumberFormat(undefined, {
  style: "currency",
  currency: "EUR",
  currencyDisplay: "symbol"
});
formatter.format(123456.789);
Your locale
€123,456.79
English (US)
en-US
€123,456.79
Dutch
nl-NL
€ 123.456,79
French
fr-FR
123 456,79 €
Japanese
ja-JP
€123,456.79
Arabic
ar-EG
‏١٢٣٬٤٥٦٫٧٩ €

Currency formatting is more than putting a symbol in front of a number. The symbol position, thousands separator, decimal separator, and rounding rules all vary by locale even when using the same currency (see how different euros are formatted compared between the Netherlands and France). Intl.NumberFormat handles all of that for you without you having to know the specifics across locales.

const moneyNL = new Intl.NumberFormat('nl-NL', {
  style: 'currency',
  currency: 'EUR',
});
const moneyFr = new Intl.NumberFormat('fr-FR', {
  style: 'currency',
  currency: 'EUR',
});

moneyNL.format(123456.789); // → "€ 123.456,79"
moneyFr.format(123456.789); // → "123 456,79 €"

You can also customize how the currency itself is displayed, such as showing the currency code instead of the symbol or even the full, localized name of the currency:

const options = {
  style: 'currency',
  currency: 'USD',
  currencyDisplay: 'name', // 'symbol' | 'narrowSymbol' | 'code' | 'name'
  currencySign: 'accounting', // 'standard' (default) | 'accounting'
};

new Intl.NumberFormat('nl-NL', options).format(123456.789);
// → "123.456,79 Amerikaanse dollar"

The difference between symbol and narrowSymbol is that narrowSymbol can be "lossy". For example, the symbol for US dollars is US$ because other countries also use dollars and it needs to differentiate between US dollars and, say, Canadian dollars, which is CA$. The narrow symbol for both is just $, which is more compact and works when you operate in a single country, but can be confusing if you have an international audience.

currencySign controls how negative currency values are displayed. Normal people expect a minus sign in front of the number, but accountants, non-conformists that they are, prefer to put negative numbers in parentheses.

const standard = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  currencySign: 'standard',
});

const accounting = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  currencySign: 'accounting',
});

standard.format(-123.45);
// → "-$123.45"

accounting.format(-123.45);
// → "($123.45)"

Units

The unit style is one of the most underused parts of Intl.NumberFormat. It formats a number with a physical unit (or units!) in a locale-appropriate way:

const formatter = new Intl.NumberFormat(undefined, {
  style: "unit",
  unit: "kilometer-per-hour",
  unitDisplay: "short"
});
formatter.format(100);
Your locale
100 km/h
English (US)
en-US
100 km/h
Dutch
nl-NL
100 km/u
French
fr-FR
100 km/h
Japanese
ja-JP
100 km/h
Arabic
ar-EG
١٠٠ كم/س

Supported units cover length, mass, temperature, speed, volume, digital size, and time. To get all of them, run Intl.supportedValuesOf("unit") in your console.

const boiling = new Intl.NumberFormat('nl-NL', {
  style: 'unit',
  unit: 'celsius',
  unitDisplay: 'short', // 'short' | 'long' | 'narrow'
});

boiling.format(100);
// → "100°C"

new Intl.NumberFormat('nl-NL', {
  style: 'unit',
  unit: 'celsius',
  unitDisplay: 'long',
}).format(100);
// → "100 graden Celsius"

The unit style also lets you combine any two units together when you need to display something like "miles per hour" or "kilometers per hour". The notation for that is unit1-per-unit2:

const speed = new Intl.NumberFormat('nl-NL', {
  style: 'unit',
  unit: 'kilometer-per-hour',
  unitDisplay: 'short', // 'short' | 'long' | 'narrow'
});

speed.format(100);
// → "100 km/u"

new Intl.NumberFormat('nl-NL', {
  style: 'unit',
  unit: 'kilometer-per-hour',
  unitDisplay: 'long',
}).format(100);
// → "100 kilometer per uur"

Breaking the output into parts

NumberFormat also has .formatToParts(). It's particularly useful for currencies, where you might want to wrap the different parts of the number and style them:

const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
});

money.formatToParts(1234.56);
// → [
//     { type: 'currency', value: '$'   },
//     { type: 'integer',  value: '1'   },
//     { type: 'group',    value: ','   },
//     { type: 'integer',  value: '234' },
//     { type: 'decimal',  value: '.'   },
//     { type: 'fraction', value: '56'  },
//   ]

Lists & Text

There are two APIs for working with natural language text: one for formatting lists and one for finding plural forms.

Intl.ListFormat

Intl.ListFormat turns arrays into natural-language lists.

const formatter = new Intl.ListFormat(undefined, {
  type: "conjunction",
  style: "long"
});
formatter.format([
  "Apples",
  "Oranges",
  "Bananas"
]);
Your locale
Apples, Oranges, and Bananas
English (US)
en-US
Apples, Oranges, and Bananas
English (UK)
en-GB
Apples, Oranges and Bananas
Dutch
nl-NL
Apples, Oranges en Bananas
French
fr-FR
Apples, Oranges et Bananas
Spanish
es-ES
Apples, Oranges y Bananas

When you need to turn an array into a human-readable list, use it instead of Array.join(', '). A comma-joined list is usually not how lists work in natural language. When you list things, you don't say "a, b, c", you say "a, b and c". ListFormat handles that for you, so you don't have to break out an Array.reduce and keep track of whether you're on the last item or not.

// ✗ Not natural language
['Apples', 'Oranges', 'Bananas'].join(', ');
// → "Apples, Oranges, Bananas"

// ✓ Natural language
const formatter = new Intl.ListFormat('en-US', { type: 'conjunction' });
formatter.format(['Apples', 'Oranges', 'Bananas']);
// → "Apples, Oranges, and Bananas"

The three list types each fit a different kind of UI copy.

Use conjunction when the items belong together and you mean "and":

new Intl.ListFormat('en', { type: 'conjunction' }).format(['Cowboy', 'Hat', 'Horse']);
// → "Cowboy, Hat, and Horse"

This is what you want for summaries like supported features or anything else where all listed items apply.

Use disjunction when the items are alternatives and you mean "or":

new Intl.ListFormat('en', { type: 'disjunction' }).format(['Cowboy', 'Hat', 'Horse']);
// → "Cowboy, Hat, or Horse"

This works for option lists, fallback choices, or messaging like "Choose email, SMS, or push notifications."

Use unit when you want a compact list without an explicit conjunction:

new Intl.ListFormat('en', { type: 'unit' }).format(['Cowboy', 'Hat', 'Horse']);
// → "Cowboy, Hat, Horse"

This is useful for tight UI labels, specs, measurements or lists of ingredients.

The style option ('long', 'short', 'narrow') affects how the connector words are shown. In English, short uses an ampersand: "Cowboy, Hat, & Horse".

In American English (en-US), a conjunction list uses the Oxford comma ("...Hat, and Horse"). Switch to British English (en-GB) and the Oxford comma disappears. So you can stop arguing about the Oxford comma in your team and just let the browser handle it for you.

Intl.PluralRules

Intl.PluralRules tells you which plural form to use for a given number. A plural form is the "s" when you go from "1 bird" to "2 birds", but it can be more complex than that in other languages.

const count = 1;
const rules = new Intl.PluralRules(undefined, { type: 'cardinal' });
rules.select(count);
Your locale
one
English (US)
en-US
one
Dutch
nl-NL
one
French
fr-FR
one
Japanese
ja-JP
other
Arabic
ar-EG
one

What's weird about PluralRules is that, unlike the other formatting APIs, it doesn't actually do the formatting, it just gives you consistent categorization for the plural forms in the language.

So for bird/birds, it would tell you that for 1 bird the plural form is "one", and for 0, 2, 3, etc. the plural form is "other". You can then use that information to display the correct word form in your UI: "one" gets nothing appended, and "other" gets an "s".

const plurals = new Intl.PluralRules('en-US');

plurals.select(0); // → "other"
plurals.select(1); // → "one"
plurals.select(2); // → "other"

const count = 5;
const label = `${count} bird${plurals.select(count) === 'other' ? 's' : ''}`;
// → "5 birds"

"Cardinal" is the default plural type, and it basically means "count". English has just two cardinal plural forms: 'one' (for the number 1) and 'other' (everything else, including 0). Other languages have more. Arabic, for example, has six plural forms: zero, one, two, few, many, and other.

Along with cardinal, you also have ordinal, which is used for ranking or ordering things: "1st", "2nd", "3rd", "4th", etc. In English, the plural forms for ordinals are one (for 1), two (for 2), few (for 3), and other (for everything else). So you can use that to get the correct suffix for any number:

const rules = new Intl.PluralRules('en-US', { type: 'ordinal' });
const suffixes = { one: 'st', two: 'nd', few: 'rd', other: 'th' };

const ordinal = (n) => `${n}${suffixes[rules.select(n)]}`;

ordinal(1); // → "1st"
ordinal(2); // → "2nd"
ordinal(3); // → "3rd"
ordinal(4); // → "4th"
ordinal(21); // → "21st"

As you can see from these two examples, it's your job to provide the right suffix or, in the case of other language, the right word form.

For example, 3 in Dutch is "drie", but 3rd is "derde". You can't easily append a suffix to get from "drie" to "derde", so you need to provide the full word forms for each plural category.

Segmentation

Intl.Segmenter

Intl.Segmenter breaks text into meaningful segments.

const segmenter = new Intl.Segmenter('default', {
  granularity: "word"
});
const segments = Array.from(segmenter.segment(text));
English (US)
en-US
How many words are in this sentence?
7 word-like segments
How·many·words·are·in·this·sentence?
Dutch
nl-NL
Hoeveel woorden zitten er in deze zin?
7 word-like segments
Hoeveel·woorden·zitten·er·in·deze·zin?
Japanese
ja-JP
この文には何語ありますか?
8 word-like segments
このありますか
Arabic
ar-EG
كم عدد الكلمات في هذه الجملة؟
6 word-like segments
كم·عدد·الكلمات·في·هذه·الجملة؟
Blue = word-likeGray dashed = whitespacePink = punctuation

Breaking up sentences into parts might sound simple, but it quickly becomes complicated when you consider all the edge cases in natural language. For example, how would you count the number of words in a sentence? You might be tempted to do this:

'How many words are in this sentence?'.split(' ').length; // → 7 ✓

Splitting on a space and then calling .length works well for English and other western languages, but it relies on the assumption that words are separated by spaces. That is not a universal rule in natural language. It falls apart here:

'これは日本語のテキストです'.split(' ').length; // → 1 ✗

Japanese doesn't use spaces between words. So splitting on a space doesn't work and you're out of luck.

But even String.length to get the number of characters isn't as straightforward as it seems. How long is this single emoji in a string?

'👨‍👩‍👧‍👦'.length; // → 11

Not one, but eleven! That's because .length doesn't count visible characters, it counts code units. And this emoji happens to be a ligature of four individual emojis joined by zero-width joiners, which are invisible characters that connect them together. So while you see one emoji, JavaScript sees eleven code units and .length returns 11.

Detecting sentences gets even trickier. Since formatted numbers, abbreviations, and contractions all contain punctuation, splitting on a period also doesn't always work:

`This article could be 100.000 words long if
 you don't look out. Do you really need
 that many examples?`.split('.').length; // → 3 ✗
// (splits on the period in "100.000" too)

Of course, some sentences might also end with a question mark or an exclamation point, so splitting on a period isn't a reliable way to count sentences either.

Intl.Segmenter handles all of these correctly and it has three different levels of granularity so you can get characters, words or sentences in a text string.

'grapheme' (default): user-perceived characters (what you see as a single character, even if it's made up of multiple code points)

const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' });
Array.from(graphemes.segment('👨‍👩‍👧‍👦')).length; // → 1 ✓
Array.from(graphemes.segment('café')).length; // → 4 ✓

'word': words, plus non-word segments such as spaces and punctuation. Each segment has an isWordLike boolean to help you filter out the non-word segments.

const words = new Intl.Segmenter('ja-JP', { granularity: 'word' });
Array.from(words.segment('これは日本語のテキストです')).filter((s) => s.isWordLike).length; // → 6 ✓ (the browser knows Japanese word boundaries)

'sentence': complete sentences, with abbreviation and number handling:

const sentences = new Intl.Segmenter('en', { granularity: 'sentence' });
Array.from(sentences.segment('I went to the dr. smith yesterday. How are you?')).map((s) => s.segment);

// → [
//     "I went to the dr. smith yesterday. ",
//     "How are you?"
//   ]

One caveat: the sentence segmenter handles dr. as an abbreviation when followed by lowercase, but if the next word is capitalized (like you would expect for a proper name), it may be treated as a sentence boundary. The segmenter is smart, but not perfect.

The segment() method returns an iterable, which is why we wrap it in Array.from(). Each item is an object with at minimum:

  • segment: the text of the segment
  • index: where it starts in the original string
  • input: the original string

For the 'word' granularity, items also have a boolean property:

  • isWordLike: true if the segment is a word rather than whitespace or punctuation

For more, check out our article on just the Segmenter API.

Collation

Intl.Collator

Intl.Collator sorts strings in a locale-aware way.

const items = ["ñ","n","o","á","A","ß","z"];
const collator = new Intl.Collator(undefined, {
  sensitivity: "base"
});
items.sort(collator.compare);
array.sort()
plain sort
Anozßáñ
Your locale
áAñnoßz
English (US)
en-US
áAñnoßz
Spanish
es-ES
áAnñoßz
German
de-DE
áAñnoßz
Swedish
sv-SE
áAñnoßz

Built-in Array.sort() sorts strings by their Unicode code points, which doesn't match the alphabetical order humans expect in many languages:

['ñ', 'n', 'o'].sort();
// → ['n', 'o', 'ñ']  ✗  (ñ has a higher code point, sorts after z)

const collator = new Intl.Collator('es'); // Spanish
['ñ', 'n', 'o'].sort(collator.compare);
// → ['n', 'ñ', 'o']  ✓  (ñ correctly sits between n and o in Spanish)

This matters anywhere you show an index, a directory, or any other sorted list.

You might already know String.prototype.localeCompare(), which also does locale-aware string comparison. Under the hood it uses Intl.Collator too, but it creates and destroys a new collator instance every time you call it. This is pretty slow (remember, creating the formatter is the expensive part) so what you can do instead is set up a collator once and then reuse its compare method.

// Works, but slow:
items.sort((a, b) => a.localeCompare(b));

// Better: creates the collator once and reuses it
const collator = new Intl.Collator('en');
items.sort(collator.compare);

Intl.Collator has a handful of useful options. The most important one is sensitivity, which controls what differences in characters are considered:

// 'base': only base letters differ ('a' ≠ 'b', but 'a' = 'á' = 'A')
// 'accent': base letters and accents differ, but not case
// 'case': base letters and case differ, but not accents
// 'variant': all differences matter (default for sort order)

const collator = new Intl.Collator('en', { sensitivity: 'base' });
collator.compare('a', 'á'); // → 0 (considered equal)

Other useful options:

  • usage: 'sort' (default) or 'search'. Use 'sort' for ordering lists. If you want to use collator to filter lists based on language-specific logic, you can use search and test if the result is 0 to check for a match. for the collator, cafe, café and CAFE are all considered equal with sensitivity: 'base', so using search means you have a more natural search experience for users in languages with accents and case differences.

  • caseFirst: 'upper', 'lower', or 'false' (default). Controls whether uppercase or lowercase sorts first when case differences matter.

  • ignorePunctuation: true or false (default). When true, punctuation differences are ignored during comparison.

  • collation: requests a specific collation variant. Common values are:

    • 'phonebk': phonebook-style sorting, used in German where ä sorts as ae, ö as oe, ü as ue
    • 'pinyin': pinyin ordering for Latin and CJK characters, common for Chinese
    • 'stroke': stroke-count ordering for CJK characters, used in Traditional Chinese
    • 'trad': traditional ordering, for example treating ch and ll as distinct letters in Spanish
    • 'emoji': recommended ordering for emoji characters
    • 'standard': the locale's standard ordering (the default)

You can get the full list your browser supports with Intl.supportedValuesOf('collation').

Numeric sorting

The numeric option is worth a special mention. By default, string sorting is done character-by-character. That means 10 sorts before 9 because 1 is less than 9. This is almost never what you want when sorting anything with numbers in it.

const files = ['chapter10.txt', 'chapter9.txt', 'chapter2.txt', 'chapter1.txt'];

// Default sort per-character
files.sort();
// → ['chapter1.txt', 'chapter10.txt', 'chapter2.txt', 'chapter9.txt']  ✗

// Numeric sort
const collator = new Intl.Collator('en', { numeric: true });
files.sort(collator.compare);
// → ['chapter1.txt', 'chapter2.txt', 'chapter9.txt', 'chapter10.txt']  ✓

This comes up frequently with file names, version numbers, and any list where users use numbers in names: image1, image2, … image10. Without numeric: true, image10 sorts right after image1 and before image2. With it, they sort the way users expect.

Conclusion

When you first look at Intl, it can feel like a loose collection of unrelated APIs. It does something with dates, currencies and also sorts lists or counts words? What an API! If you only look at the constructor names, it can seem like a lot to memorize.

But Intl is built on one core idea: you have a type of data, and you want an easy way to format it in natural language. You pick a locale. You pick some options. You create a formatter, parts formatter, comparator, or segmenter. Then you reuse it with your data. Whether you're formatting dates, numbers, currencies, lists, plurals, words, or sorted strings, the API shape stays similar.

Understanding that shared foundation makes the whole API much easier to reason about and easier to reach for when you come across a string formatting task. It means less hand-rolled formatting code, fewer dependencies, and fewer subtle bugs where a value looks correct to you but confusing or wrong to someone in another locale.

The browser already knows how this stuff works and did the hard work for you. Intl gives you a way to use that knowledge directly.

Build your next project with Polypane

  • Use all features on all plans
  • On Mac, Windows and Linux
  • 14-day free trial – no credit card needed
Start Your Free Trial
Polypane UI