Format a Date Range with Intl.DateTimeFormat

To format a date range, use formatter.formatRange(start, end) — it collapses shared fields ('Mar 3 - 5, 2024') and uses the locale's range separator. Part of Mastering Intl.DateTimeFormat Options.

Why this scenario is tricky

Formatting a date range is deceptively hard to do well by hand because a good range display elides the shared parts — "Mar 3 – 5, 2024" rather than "Mar 3, 2024 – Mar 5, 2024" — and the rules for what to elide are locale-specific and depend on which fields the two endpoints share. If the months differ you keep both months; if only the days differ you show the month once; if the years differ you show both years. Reproducing that logic yourself, per locale, is a substantial and bug-prone undertaking, and naive concatenation of two formatted dates with a dash produces verbose, unidiomatic output.

Intl.DateTimeFormat.prototype.formatRange encapsulates exactly this. Given two dates and a set of format options, it produces the locale-appropriate condensed range, eliding shared components and using the locale's range separator (which is not always a hyphen). The trick is simply to know this method exists and to use it instead of formatting each endpoint separately — most developers reach for two format calls and a manual join, missing the built-in that does it correctly.

Concatenating two formatted dates with a hyphen ('Mar 3, 2024' + ' - ' + 'Mar 5, 2024') is verbose and wrong in most locales: it repeats the shared month and year and uses the wrong dash. formatRange knows to collapse common parts and pick the locale's range dash.

Ranges are more than two dates joinedConcatenation repeats shared fieldsRanges are more than two dates joinedfmt(a) + ' - ' + fmt(b)'Mar 3, 2024 - Mar 5, 2024'fmt.formatRange(a, b)'Mar 3 - 5, 2024'formatRange collapses shared month/year and uses the locale separator.

Minimal working solution

With a formatter configured for the fields you want ({ month: 'short', day: 'numeric', year: 'numeric' }), fmt.formatRange(start, end) returns "Mar 3 – 5, 2024", automatically showing the month and year once because they are shared. Change the locale and the elision rules, separator, and field order all adjust: a German formatter produces the German-idiomatic range without any change to your call. This is the entire minimal solution — configure the formatter once, call formatRange with the two endpoints.

The companion formatRangeToParts returns the range broken into typed tokens (which part is the start month, which is the separator, which is the shared year), which you need when you want to wrap pieces in markup — bolding the days, say, or styling the two endpoints differently. Reaching for the parts API rather than string-splitting the formatted output is what keeps custom markup robust across locales, since you never assume where the separator falls.

const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
fmt.formatRange(new Date('2024-03-03'), new Date('2024-03-05')); // 'Mar 3 - 5, 2024'

formatRangeOne call renders the whole spanformatRangestart, endformatRangecollapsed span

Full production version

A production range formatter caches the Intl.DateTimeFormat per (locale, options) and exposes both formatRange for plain text and formatRangeToParts for rich markup. Because range formatting is common in schedules, booking summaries, and event listings, and because constructing a formatter is relatively costly, the cache is a real performance win in a list of many ranges. Keep the field options aligned with the density of the slot — dateStyle: 'medium' for a compact summary, explicit fields for precise control.

Two edge behaviors are worth handling deliberately. When the two endpoints are identical, formatRange collapses to a single formatted date rather than an empty range, which is usually what you want, but confirm it matches your UI's expectation. And always pass a timeZone when the range includes a time, so the endpoints are projected into the intended zone rather than the host's — a range that spans midnight in one zone might not in another, and the displayed days should reflect the zone you mean. Delegating the elision and separator logic to Intl while controlling the zone and fields yourself gives correct, idiomatic ranges in every locale.

// Cache the formatter; formatRangeToParts gives you the pieces for custom markup.
const fmt = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' });
const parts = fmt.formatRangeToParts(new Date('2024-03-03'), new Date('2024-03-05'));
// each part is tagged source: 'startRange' | 'endRange' | 'shared'

formatRangeToPartsTagged pieces for custom renderingformatRangeToPartsstart,endformatRangeToPartstagged partscustom markup

Verification snippet

Format a Date Range assertionsKey cases assert correctlyAssertions that prove the edge caseen-US same month'Mar 3 - 5, 2024'cross-month'Mar 30 - Apr 2'de-DElocale dashequal datessingle date

Common pitfalls

Format a Date Range pitfallsCommon mistakes and their fixesWrongRightconcatenate two formatsrepeats fieldsformatRange(a,b)collapses shared partshard-code ' - 'wrong dash per localeformatRangeToPartsstyled output

Frequently Asked Questions

How do I format a date range in JavaScript?

Build an Intl.DateTimeFormat and call formatRange(startDate, endDate). It collapses fields the two dates share — printing 'Mar 3 - 5, 2024' instead of repeating the month and year — and uses the correct range separator for the locale.

How do I customize the markup of a formatted range?

Use formatRangeToParts(start, end). It returns an array of tokens, each tagged with a source of startRange, endRange, or shared, so you can wrap the start and end portions in your own elements while keeping locale-correct text.

How do I format a date range with Intl in JavaScript?

Use Intl.DateTimeFormat.prototype.formatRange: configure a formatter with the fields you want, then call fmt.formatRange(start, end). It produces the locale-appropriate condensed range like 'Mar 3 – 5, 2024', eliding shared month and year and using the locale's separator. Use formatRangeToParts when you need the pieces as typed tokens for custom markup.

Why not just format two dates and join them with a dash?

Because a good range elides the parts the endpoints share — 'Mar 3 – 5, 2024' rather than 'Mar 3, 2024 – Mar 5, 2024' — and the elision rules, field order, and separator are locale-specific. Naive concatenation is verbose and unidiomatic, and reproducing the elision logic per locale is bug-prone. formatRange encapsulates all of it and adapts automatically when you change the locale.

How do I apply custom styling to parts of a formatted range?

Use formatRangeToParts, which returns the range as typed tokens identifying the start month, the separator, the shared year, and so on. Iterate the parts and wrap the ones you want to style in markup, rather than string-splitting the formatted output. This stays correct across locales because you never assume where the separator or shared components fall.

What happens when both ends of the range are the same date?

formatRange collapses to a single formatted date rather than rendering an empty or doubled range, which is usually the desired behavior. Confirm it matches your UI's expectation for a zero-length range, and if you need a different treatment — such as showing 'Mar 3 only' — detect the equal-endpoints case yourself before calling formatRange.

Do I need a time zone when formatting a date range?

Yes, whenever the range includes a time. Pass a timeZone so both endpoints are projected into the intended zone rather than the host's, because a range that spans midnight in one zone may not in another, and the displayed days should reflect the zone you mean. For pure calendar dates without a time component the zone matters less, but being explicit avoids host-zone surprises.