How to format date ranges like Jan 1 - Jan 5
Use JavaScript to display date ranges with locale-appropriate formatting and intelligent redundancy removal
Introduction
Date ranges appear throughout web applications. Booking systems show availability from January 1 to January 5, event calendars display multi-day conferences, analytics dashboards show data for specific time periods, and reports cover fiscal quarters or custom date spans. These ranges communicate that something applies to all dates between two endpoints.
When you format date ranges manually by concatenating two formatted dates with a dash, you create unnecessarily verbose output. A range from January 1, 2024 to January 5, 2024 does not need to repeat the month and year in the second date. The output "January 1 - 5, 2024" conveys the same information more concisely by omitting redundant components.
JavaScript provides the formatRange() method on Intl.DateTimeFormat to handle date range formatting automatically. This method determines which date components to include in each part of the range, applying locale-specific conventions for separators and formatting while removing unnecessary repetition.
Why date ranges need intelligent formatting
Different date ranges require different levels of detail. A range within the same month needs less information than a range spanning multiple years. The optimal format depends on which date components differ between the start and end dates.
For a range within the same day, you only need to show the time difference: "10:00 AM - 2:00 PM". Repeating the full date for both times adds no information.
For a range within the same month, you show the month once and list both day numbers: "January 1-5, 2024". Including "January" twice makes the output harder to read without adding clarity.
For a range spanning different months in the same year, you show both months but can omit the year from the first date: "December 25, 2024 - January 2, 2025" needs the full information, but "January 15 - February 20, 2024" can omit the year from the first date in some locales.
For ranges spanning multiple years, you must include the year for both dates: "December 1, 2023 - March 15, 2024".
Manual implementation of these rules requires checking which date components differ and constructing format strings accordingly. Different locales also apply these rules differently, using different separators and ordering conventions. The formatRange() method encapsulates this logic.
Using formatRange to format date ranges
The formatRange() method accepts two Date objects and returns a formatted string. Create an Intl.DateTimeFormat instance with your desired locale and options, then call formatRange() with the start and end dates.
const formatter = new Intl.DateTimeFormat("en-US");
const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 5);
console.log(formatter.formatRange(start, end));
// Output: "1/1/24 – 1/5/24"
The formatter applies the default US English date format to both dates and connects them with an en dash. For dates within the same month, the output still shows both complete dates because the default format is quite short.
You can specify which date components to include using the same options available for regular date formatting.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 5);
console.log(formatter.formatRange(start, end));
// Output: "January 1 – 5, 2024"
Now the formatter intelligently omits the month and year from the second date because they match the first date. The output shows only the day number for the end date, making the range more readable.
How formatRange optimizes date range output
The formatRange() method examines both dates and determines which components differ. It includes only the necessary components in each part of the range.
For dates in the same month and year, only the end day appears in the second part.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(formatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"
console.log(formatter.formatRange(
new Date(2024, 0, 15),
new Date(2024, 0, 20)
));
// Output: "January 15 – 20, 2024"
Both ranges show the month and year once, with just the day numbers connected by the range separator.
For dates in different months of the same year, both months appear but the year appears only once.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(formatter.formatRange(
new Date(2024, 0, 15),
new Date(2024, 1, 20)
));
// Output: "January 15 – February 20, 2024"
The formatter includes both month names but places the year at the end, applying it to the entire range.
For dates spanning different years, both complete dates appear.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(formatter.formatRange(
new Date(2023, 11, 25),
new Date(2024, 0, 5)
));
// Output: "December 25, 2023 – January 5, 2024"
Each date includes its full year because the years differ. The formatter cannot omit either year without creating ambiguity.
Formatting date and time ranges
When your format includes time components, the formatRange() method applies the same intelligent omission to time fields.
For times on the same day, only the time components differ in the output.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric"
});
const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 1, 14, 30);
console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024, 10:00 AM – 2:30 PM"
The date appears once, followed by the time range. The formatter recognizes that repeating the full date and time for the end would add no useful information.
For times on different days, both complete date and time values appear.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric"
});
const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 2, 14, 30);
console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024, 10:00 AM – January 2, 2024, 2:30 PM"
Both dates and times appear because they represent different days. The formatter cannot safely omit any components.
Formatting date ranges in different locales
Range formatting adapts to each locale's conventions for date component order, separators, and which components to omit.
const enFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(enFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"
const deFormatter = new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(deFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "1.–5. Januar 2024"
const jaFormatter = new Intl.DateTimeFormat("ja-JP", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(jaFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "2024年1月1日~5日"
English places the month first and shows the year at the end. German places the day numbers first with periods, then the month name, then the year. Japanese uses year-month-day order and the wave dash (~) as the range separator. Each locale applies its own conventions for which components to show once versus twice.
These differences extend to cross-month ranges.
const enFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(enFormatter.formatRange(
new Date(2024, 0, 15),
new Date(2024, 1, 20)
));
// Output: "January 15 – February 20, 2024"
const deFormatter = new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(deFormatter.formatRange(
new Date(2024, 0, 15),
new Date(2024, 1, 20)
));
// Output: "15. Januar – 20. Februar 2024"
const frFormatter = new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric"
});
console.log(frFormatter.formatRange(
new Date(2024, 0, 15),
new Date(2024, 1, 20)
));
// Output: "15 janvier – 20 février 2024"
All three locales show both month names but position them differently relative to the day numbers. The formatters handle these variations automatically.
Formatting date ranges with different styles
You can use the dateStyle option to control the overall format length, just as with single date formatting.
const shortFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "short"
});
console.log(shortFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "1/1/24 – 1/5/24"
const mediumFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium"
});
console.log(mediumFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "Jan 1 – 5, 2024"
const longFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "long"
});
console.log(longFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"
const fullFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "full"
});
console.log(fullFormatter.formatRange(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
));
// Output: "Monday, January 1 – Friday, January 5, 2024"
The short style produces numeric dates and does not apply intelligent omission because the format is already compact. The medium and long styles abbreviate or spell out the month and omit redundant components. The full style includes weekday names for both dates.
Using formatRangeToParts for custom styling
The formatRangeToParts() method returns an array of objects representing the components of the formatted range. This allows you to style or manipulate individual parts of the range output.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
const parts = formatter.formatRangeToParts(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
);
console.log(parts);
The output is an array of objects, each with type, value, and source properties.
[
{ type: "month", value: "January", source: "startRange" },
{ type: "literal", value: " ", source: "startRange" },
{ type: "day", value: "1", source: "startRange" },
{ type: "literal", value: " – ", source: "shared" },
{ type: "day", value: "5", source: "endRange" },
{ type: "literal", value: ", ", source: "shared" },
{ type: "year", value: "2024", source: "shared" }
]
The type property identifies the component: month, day, year, or literal text. The value property contains the formatted text. The source property indicates whether the component belongs to the start date, end date, or is shared between them.
You can use these parts to create custom HTML with styling for different components.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
const parts = formatter.formatRangeToParts(
new Date(2024, 0, 1),
new Date(2024, 0, 5)
);
let html = "";
parts.forEach(part => {
if (part.type === "month") {
html += `<span class="month">${part.value}</span>`;
} else if (part.type === "day") {
html += `<span class="day">${part.value}</span>`;
} else if (part.type === "year") {
html += `<span class="year">${part.value}</span>`;
} else if (part.type === "literal" && part.source === "shared" && part.value.includes("–")) {
html += `<span class="separator">${part.value}</span>`;
} else {
html += part.value;
}
});
console.log(html);
// Output: <span class="month">January</span> <span class="day">1</span><span class="separator"> – </span><span class="day">5</span>, <span class="year">2024</span>
This technique preserves locale-specific formatting while allowing custom visual styling.
What happens when dates are equal
When you pass the same date for both the start and end parameters, formatRange() outputs a single formatted date rather than a range.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
const date = new Date(2024, 0, 1);
console.log(formatter.formatRange(date, date));
// Output: "January 1, 2024"
The formatter recognizes that a range with identical endpoints is not truly a range and formats it as a single date. This behavior applies even when the Date objects are different instances with the same value.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 1, 10, 0);
console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024"
Even though these are separate Date objects, they represent the same date and time. The formatter outputs a single date because the range has zero duration at the precision level of the format options. Since the format does not include time components, the times are irrelevant and the dates are considered equal.
When to use formatRange vs manual formatting
Use formatRange() when displaying date ranges to users. This applies to booking date ranges, event duration, report periods, availability windows, or any other time spans. The method ensures correct locale-specific formatting and optimal component omission.
Avoid formatRange() when you need to display multiple unrelated dates. A list of deadlines like "January 1, January 15, February 1" should use regular format() calls for each date rather than treating them as ranges.
Also avoid formatRange() when showing comparisons or differences between dates. If you are displaying how much earlier or later one date is compared to another, that represents a relative time calculation rather than a range.