Display Islamic (Hijri) Dates in JavaScript
To display a Hijri (Islamic) date, format an instant with Intl.DateTimeFormat using calendar: 'islamic' (or the islamic-umalqura variant), or attach the calendar to a Temporal value with .withCalendar('islamic-umalqura') and read .year, .month, and .day. This page is part of Calendar Systems and Era Handling.
Why This Scenario Is Tricky
There is no single "Islamic calendar." The Hijri calendar is lunar, and the start of each month historically depends on the sighting of the new moon β something that can legitimately differ by a day between observers and computation methods. CLDR therefore ships several variants, and they routinely disagree by one day:
islamic-umalquraβ the Umm al-Qura calendar used officially in Saudi Arabia; the most common choice for civil applications.islamic(a.k.a.islamic-civil/islamicc) β a tabular, arithmetic calendar with fixed month-length rules.islamic-tblaβ tabular with an astronomical (Thursday) epoch.islamic-rgsaβ Saudi sighting-based.
Pick the wrong variant and a date can render as, say, Ramadan 14 instead of Ramadan 15. Legacy Date offers nothing here β it is Gregorian-only with no calendar option β so the conversion has to come from Intl or Temporal, both of which read CLDR data.
The matrix below shows how the same Gregorian instant maps to slightly different Hijri days depending on the chosen variant.
Minimal Working Solution
The fastest way to display a Hijri date is Intl.DateTimeFormat with a calendar-tagged locale:
// '-u-ca-islamic-umalqura' selects the Umm al-Qura variant via the locale extension
const fmt = new Intl.DateTimeFormat('en-US-u-ca-islamic-umalqura', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC', // pin the zone so the displayed civil day is deterministic
});
console.log(fmt.format(new Date('2024-03-11T00:00:00Z'))); // 'Ramadan 1, 1445 AH'
To get the numeric Hijri fields (not just a display string), convert with Temporal:
import { Temporal } from '@js-temporal/polyfill';
const hijri = Temporal.PlainDate.from('2024-03-11').withCalendar('islamic-umalqura');
console.log(hijri.year); // 1445
console.log(hijri.month); // 9 (Ramadan)
console.log(hijri.day); // 1
Full Production Version
A production converter should let the caller choose the variant, validate it against a known set, cache the formatter per locale+variant, and return both structured fields and a localized string.
import { Temporal } from '@js-temporal/polyfill';
type IslamicVariant =
| 'islamic-umalqura'
| 'islamic-civil'
| 'islamic-tbla'
| 'islamic-rgsa';
interface HijriDate {
year: number;
month: number; // 1 = Muharram β¦ 9 = Ramadan β¦ 12 = DhuΚ»l-Hijjah
day: number;
display: string;
}
// Cache one formatter per locale+variant key β constructing Intl formatters is costly
const formatterCache = new Map<string, Intl.DateTimeFormat>();
function getFormatter(locale: string, variant: IslamicVariant): Intl.DateTimeFormat {
const key = `${locale}|${variant}`;
let fmt = formatterCache.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(`${locale}-u-ca-${variant}`, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC', // PlainDate has no zone; UTC keeps the civil day stable on servers
});
formatterCache.set(key, fmt);
}
return fmt;
}
export function toHijri(
input: string | Temporal.PlainDate,
variant: IslamicVariant = 'islamic-umalqura',
locale = 'en-US'
): HijriDate {
const iso =
typeof input === 'string' ? Temporal.PlainDate.from(input) : input;
// Note: Temporal exposes the variants under the same ids used by Intl
const hijri = iso.withCalendar(variant);
// Intl.format() needs a Date; build it at UTC midnight to avoid a day-shift west of UTC
const asDate = new Date(Date.UTC(iso.year, iso.month - 1, iso.day));
return {
year: hijri.year,
month: hijri.month,
day: hijri.day,
display: getFormatter(locale, variant).format(asDate),
};
}
console.log(toHijri('2024-03-11'));
// { year: 1445, month: 9, day: 1, display: 'Ramadan 1, 1445 AH' }
console.log(toHijri('2024-03-11', 'islamic-civil').display);
// 'Ramadan 2, 1445 AH' β one day later than Umm al-Qura for this instant
Verifying That Variants Differ by a Day
This assertion block proves the day-offset behaviour is real, so you choose a variant deliberately rather than discover the mismatch in production:
import { Temporal } from '@js-temporal/polyfill';
const iso = Temporal.PlainDate.from('2024-03-11');
const umalqura = iso.withCalendar('islamic-umalqura');
const civil = iso.withCalendar('islamic-civil');
// Same Gregorian day, same Hijri month/year hereβ¦
console.assert(umalqura.year === 1445, 'Umm al-Qura year is 1445');
console.assert(umalqura.month === 9, 'Umm al-Qura month is Ramadan (9)');
// β¦but the day-of-month can differ between variants
console.assert(
umalqura.day !== civil.day,
'Umm al-Qura and tabular civil disagree on the day for this instant'
);
console.assert(civil.day === umalqura.day + 1, 'Civil is one day ahead here');
Common Pitfalls
- Assuming
'islamic'is a single fixed calendar. It resolves to a tabular variant that can differ from the official Umm al-Qura date by a day. Wrong:new Intl.DateTimeFormat('en-u-ca-islamic')for a Saudi civil app. Right: name the variant explicitly, e.g.'...-u-ca-islamic-umalqura'. - Reading Hijri fields off a Gregorian value.
iso.monthis the Gregorian month, not Ramadan. Wrong:Temporal.PlainDate.from('2024-03-11').month. Right:.withCalendar('islamic-umalqura').month. - Dropping the
timeZonewhen formatting. WithouttimeZone: 'UTC', a midnightDatecan render as the previous Hijri day in negative-offset zones. Wrong:new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', { dateStyle: 'long' }). Right: addtimeZone: 'UTC'. - Rebuilding the formatter per row. Wrong:
dates.map(d => new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', opts).format(d)). Right: cache one formatter per locale+variant and reuse it.
Frequently Asked Questions
Which Islamic calendar variant should I use?
For civil/official use, islamic-umalqura (Umm al-Qura) is the safest default and matches Saudi government dates. Use islamic-civil (tabular) only when you need a purely arithmetic, deterministic calendar with no sighting dependency. Whatever you choose, name it explicitly rather than relying on the bare 'islamic' id.
Why do two Hijri converters show dates one day apart?
Because the Hijri calendar's month start depends on the new moon, and the CLDR variants model that with different rules β some sighting-based, some tabular. A Β±1 day spread between islamic-umalqura, islamic-civil, and islamic-tbla for the same Gregorian instant is expected, not a bug. Pick one variant and use it consistently across storage and display.