How to format ranges like 3-5 or 100-200

Use JavaScript to display number ranges with locale-appropriate formatting

Introduction

Number ranges appear throughout user interfaces. Price ranges show as $100-$200, page numbers as 1-10, and quantity estimates as 3-5 items. These ranges communicate that a value falls between two endpoints, providing users with bounded information rather than a single precise number.

When you hardcode the separator between range values, you assume all users follow the same typographic conventions. English speakers typically use hyphens or en dashes for ranges, but other languages use different symbols or words. German uses "bis" between numbers, while some languages place spaces around separators.

JavaScript provides the formatRange() method on Intl.NumberFormat to handle range formatting automatically. This method applies locale-specific conventions to both the numbers and the separator, ensuring ranges display correctly for users around the world.

Why number ranges need locale-specific formatting

Different cultures developed different conventions for expressing ranges. These conventions involve both the separator symbol and the spacing around it.

In US English, ranges typically use an en dash with no spaces: 3-5, 100-200. In some style guides, spaces appear around the dash: 3 - 5. The exact convention varies by context and publication standards.

In German, ranges often use "bis" as a separator: 3 bis 5, 100 bis 200. This word-based approach makes the range relationship explicit rather than relying on punctuation.

In Spanish, ranges can use a dash like English or the word "a": 3-5 or 3 a 5. The choice depends on the specific Spanish-speaking region and context.

When formatting ranges that include currency or units, the complexity increases. A price range might show as $100-$200 in US English, but as 100 €-200 € in German, or 100-200 € with the symbol appearing only once. Different locales place currency symbols differently and handle repetition differently.

Manual range formatting requires knowing these conventions and implementing locale-specific logic. The Intl API encapsulates this knowledge, applying the appropriate formatting based on the locale.

Using formatRange to format number ranges

The formatRange() method accepts two numbers and returns a formatted string representing the range. Create an Intl.NumberFormat instance with your desired locale and options, then call formatRange() with the start and end values.

const formatter = new Intl.NumberFormat("en-US");

console.log(formatter.formatRange(3, 5));
// Output: "3–5"

console.log(formatter.formatRange(100, 200));
// Output: "100–200"

console.log(formatter.formatRange(1000, 5000));
// Output: "1,000–5,000"

The formatter applies thousands separators to both numbers in the range and uses an appropriate separator between them. For US English, this is an en dash without spaces.

You can format the same range for different locales by changing the locale identifier.

const usFormatter = new Intl.NumberFormat("en-US");
console.log(usFormatter.formatRange(100, 200));
// Output: "100–200"

const deFormatter = new Intl.NumberFormat("de-DE");
console.log(deFormatter.formatRange(100, 200));
// Output: "100–200"

const esFormatter = new Intl.NumberFormat("es-ES");
console.log(esFormatter.formatRange(100, 200));
// Output: "100-200"

Each locale applies its own conventions for the separator and spacing. The API handles these details automatically based on the locale's typographic standards.

Formatting currency ranges

Range formatting works with any number formatting options, including currency. When you format currency ranges, the formatter handles both the currency symbol placement and the range separator.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0
});

console.log(formatter.formatRange(100, 200));
// Output: "$100 – $200"

console.log(formatter.formatRange(1000, 5000));
// Output: "$1,000 – $5,000"

The formatter places the currency symbol before each number in the range. This makes it clear that both values represent currency amounts.

Different locales place currency symbols differently.

const usFormatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0
});

console.log(usFormatter.formatRange(100, 200));
// Output: "$100 – $200"

const deFormatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
  maximumFractionDigits: 0
});

console.log(deFormatter.formatRange(100, 200));
// Output: "100–200 €"

The German formatter places the euro symbol after the range rather than before each number. This follows German typographic conventions for currency ranges.

What happens when range values are approximately equal

When the start and end values round to the same number after formatting, the formatter collapses the range and may add an approximation symbol.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0
});

console.log(formatter.formatRange(100, 200));
// Output: "$100 – $200"

console.log(formatter.formatRange(100, 120));
// Output: "$100 – $120"

console.log(formatter.formatRange(100.2, 100.8));
// Output: "~$100"

The third example shows two values that round to the same whole number. Instead of displaying "$100 – $100", which conveys no range information, the formatter outputs "~$100". The tilde symbol indicates the value is approximate.

This behavior applies when formatting options cause the start and end values to appear identical.

const formatter = new Intl.NumberFormat("en-US", {
  maximumFractionDigits: 1
});

console.log(formatter.formatRange(2.9, 3.1));
// Output: "~3"

console.log(formatter.formatRange(2.94, 2.96));
// Output: "~2.9"

The formatter inserts the approximation symbol only when needed. When the values round to different numbers, it displays them as a standard range.

Formatting ranges with decimal places

Range formatting preserves decimal place settings from the formatter options. You can control precision for both values in the range.

const formatter = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});

console.log(formatter.formatRange(3.5, 5.7));
// Output: "3.50–5.70"

console.log(formatter.formatRange(100, 200));
// Output: "100.00–200.00"

The formatter applies the decimal place settings to both numbers in the range. This ensures consistent precision across the entire range display.

You can combine decimal formatting with currency or other styles.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});

console.log(formatter.formatRange(99.99, 199.99));
// Output: "$99.99 – $199.99"

Formatting number ranges in different languages

Range formatting adapts to each locale's conventions for numbers, separators, and spacing.

const enFormatter = new Intl.NumberFormat("en-US");
console.log(enFormatter.formatRange(1000, 5000));
// Output: "1,000–5,000"

const deFormatter = new Intl.NumberFormat("de-DE");
console.log(deFormatter.formatRange(1000, 5000));
// Output: "1.000–5.000"

const frFormatter = new Intl.NumberFormat("fr-FR");
console.log(frFormatter.formatRange(1000, 5000));
// Output: "1 000–5 000"

const jaFormatter = new Intl.NumberFormat("ja-JP");
console.log(jaFormatter.formatRange(1000, 5000));
// Output: "1,000~5,000"

English uses commas for thousands separators and an en dash for the range. German uses periods for thousands and an en dash. French uses spaces for thousands and an en dash. Japanese uses commas for thousands and a wave dash (~) for the range.

These differences extend to currency formatting.

const enFormatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});
console.log(enFormatter.formatRange(100, 200));
// Output: "$100.00 – $200.00"

const deFormatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR"
});
console.log(deFormatter.formatRange(100, 200));
// Output: "100,00–200,00 €"

const jaFormatter = new Intl.NumberFormat("ja-JP", {
  style: "currency",
  currency: "JPY"
});
console.log(jaFormatter.formatRange(100, 200));
// Output: "¥100~¥200"

Each locale applies its own rules for currency symbol placement, decimal separators, and range separators. The API handles all these variations automatically.

Combining formatRange with compact notation

Range formatting works with compact notation, allowing you to display ranges like 1K-5K or 1M-5M.

const formatter = new Intl.NumberFormat("en-US", {
  notation: "compact"
});

console.log(formatter.formatRange(1000, 5000));
// Output: "1K–5K"

console.log(formatter.formatRange(1000000, 5000000));
// Output: "1M–5M"

console.log(formatter.formatRange(1200, 4800));
// Output: "1.2K–4.8K"

The formatter applies compact notation to both values in the range. This keeps the output concise while still conveying the range information.

When the range spans different magnitude levels, the formatter handles each value appropriately.

const formatter = new Intl.NumberFormat("en-US", {
  notation: "compact"
});

console.log(formatter.formatRange(500, 1500));
// Output: "500–1.5K"

console.log(formatter.formatRange(900000, 1200000));
// Output: "900K–1.2M"

The start value might not use compact notation while the end value does, or they might use different magnitude indicators. The formatter makes these decisions based on each value's size.

Using formatRangeToParts for custom styling

The formatRangeToParts() method returns an array of objects representing the parts of the formatted range. This allows you to style or manipulate individual components of the range.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0
});

const parts = formatter.formatRangeToParts(100, 200);
console.log(parts);

The output is an array of objects, each with type, value, and source properties.

[
  { type: "currency", value: "$", source: "startRange" },
  { type: "integer", value: "100", source: "startRange" },
  { type: "literal", value: " – ", source: "shared" },
  { type: "currency", value: "$", source: "endRange" },
  { type: "integer", value: "200", source: "endRange" }
]

The type property identifies what the part represents: currency symbol, integer, decimal separator, or literal text. The value property contains the formatted text. The source property indicates whether the part belongs to the start value, end value, or is shared between them.

You can use these parts to create custom HTML with different styling for different components.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0
});

const parts = formatter.formatRangeToParts(100, 200);
let html = "";

parts.forEach(part => {
  if (part.type === "currency") {
    html += `<span class="currency-symbol">${part.value}</span>`;
  } else if (part.type === "integer") {
    html += `<span class="amount">${part.value}</span>`;
  } else if (part.type === "literal") {
    html += `<span class="separator">${part.value}</span>`;
  } else {
    html += part.value;
  }
});

console.log(html);
// Output: <span class="currency-symbol">$</span><span class="amount">100</span><span class="separator"> – </span><span class="currency-symbol">$</span><span class="amount">200</span>

This technique lets you apply CSS classes, add tooltips, or implement other custom behaviors while preserving correct locale-specific formatting.

Handling edge cases with formatRange

The formatRange() method includes error handling for invalid inputs. If either parameter is undefined, a TypeError is thrown. If either parameter is NaN or cannot be converted to a number, a RangeError is thrown.

const formatter = new Intl.NumberFormat("en-US");

try {
  console.log(formatter.formatRange(100, undefined));
} catch (error) {
  console.log(error.name);
  // Output: "TypeError"
}

try {
  console.log(formatter.formatRange(NaN, 200));
} catch (error) {
  console.log(error.name);
  // Output: "RangeError"
}

When working with user input or data from external sources, validate that values are valid numbers before passing them to formatRange().

The method accepts numbers, BigInt values, or strings that represent valid numbers.

const formatter = new Intl.NumberFormat("en-US");

console.log(formatter.formatRange(100, 200));
// Output: "100–200"

console.log(formatter.formatRange(100n, 200n));
// Output: "100–200"

console.log(formatter.formatRange("100", "200"));
// Output: "100–200"

String inputs are parsed as numbers, preserving precision without floating-point conversion issues.

When to use formatRange vs manual formatting

Use formatRange() when displaying ranges to users. This applies to price ranges, quantity ranges, measurement ranges, page numbers, or any other bounded values. The method ensures correct locale-specific formatting without requiring you to implement separator logic.

Avoid formatRange() when you need to display multiple separate values that are not semantically related as a range. For example, showing a list of prices like "$100, $150, $200" should use regular format() calls for each value rather than treating them as ranges.

Also avoid formatRange() when the relationship between values is not a numeric range. If you are showing a comparison or difference, use appropriate formatting for that context rather than range formatting.