Saudi Arabia's official Hijri calendar is Umm al-Qura, and dates like "1 Ramadan 1448" show up in contracts, government forms, school calendars and HR systems across the Gulf. JavaScript can format Hijri dates without a library thanks to Intl, but it has no direct API for the reverse (Hijri → Gregorian), and there are a few traps that cause classic off-by-one-day bugs.
Disclosure: I maintain an open-source Hijri/Arabic library, mentioned at the end. Everything before that uses only built-in Intl.
Gregorian → Hijri with Intl
const fmt = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', {
timeZone: 'UTC', year: 'numeric', month: 'numeric', day: 'numeric',
});
function toHijri(date: Date) {
const p = Object.fromEntries(fmt.formatToParts(date).map((x) => [x.type, x.value]));
return { year: parseInt(p.year), month: Number(p.month), day: Number(p.day) };
}
toHijri(new Date(Date.UTC(2024, 2, 11))); // { year: 1445, month: 9, day: 1 } (1 Ramadan 1445)
Use formatToParts rather than parsing the formatted string: the string's layout changes with the locale (and with locales like ar, so do the digits), while the parts are stable.
That's the easy direction. Here are the four things that go wrong.
Trap 1: "islamic" isn't one calendar
ICU ships several Hijri variants, and they don't always agree. For 11 March 2024:
| Calendar | Result |
|---|---|
islamic-umalqura |
1 Ramadan 1445 |
islamic |
1 Ramadan 1445 |
islamic-civil |
1 Ramadan 1445 |
islamic-tbla |
2 Ramadan 1445 |
islamic-civil and islamic-tbla are arithmetic calendars (fixed rules for month lengths); islamic-umalqura uses ICU's data for the Umm al-Qura calendar published in Saudi Arabia. If your users are in Saudi Arabia (or you're matching government documents), ask for islamic-umalqura explicitly.
Trap 2: don't rely on the locale's default calendar
It's tempting to write new Intl.DateTimeFormat('ar-SA') and expect Hijri output. In Node 24 (ICU 78), that formats 11 March 2024 as ١١/٣/٢٠٢٤, a Gregorian date, and resolvedOptions().calendar is "gregory". Locale defaults are data, and data changes between runtime versions. Always put the calendar in the locale string (-u-ca-islamic-umalqura) or pass calendar: 'islamic-umalqura'.
Trap 3: time zones
A Date is an instant, not a calendar day. Take midnight on 11 March 2024 in Riyadh:
const d = new Date('2024-03-11T00:00:00+03:00');
// formatted with timeZone: 'UTC' → 29 Sha'ban 1445 (it's still 10 March in UTC)
// formatted with timeZone: 'Asia/Riyadh' → 1 Ramadan 1445
Pick one convention and stick to it. Either build dates with Date.UTC(...) and always format in UTC (what the snippets here do), or always pass the user's time zone. Mixing the two is where "the date is off by one" reports come from.
Trap 4: the official calendar isn't the announced date
Umm al-Qura is a calendar computed in advance. The start of Ramadan and the Eids, however, is usually decided by an official announcement based on moon sighting, so the observed date can differ from the calendar by a day, and can differ between countries. Your conversion can be correct and still not match the announced date, so say so in the UI wherever it matters (countdowns, Ramadan timetables).
Hijri → Gregorian: the direction Intl can't do
Intl only formats. There's no API that takes "1 Ramadan 1448" and gives you a Date. (The answer you'll often find, "use Intl.DateTimeFormat with an Islamic calendar", only covers the other direction.)
You can still build it on top of Intl: estimate the date, format it with the Hijri calendar, and step until the formatter agrees. The function below is my implementation of that search; Intl does the calendar work, the loop just finds the right day.
// Hijri → Gregorian with Intl alone: estimate, then step until the formatter agrees.
function hijriToGregorianIntl(year: number, month: number, day: number): Date {
// Mean Islamic year ≈ 354.367 days, counted from about mid-July 622 (1 Muharram 1 AH).
// It's only a starting guess: the loop corrects it.
const estimate = Date.UTC(622, 6, 16) + ((year - 1) * 354.367 + (month - 1) * 29.53 + (day - 1)) * 86_400_000;
let t = estimate;
for (let i = 0; i < 60; i++) {
const h = toHijri(new Date(t));
const monthDiff = (year * 12 + month) - (h.year * 12 + h.month); // continuous month index
const diff = monthDiff * 29.5 + (day - h.day);
if (h.year === year && h.month === month && h.day === day) return new Date(t);
t += Math.sign(diff) * Math.max(1, Math.round(Math.abs(diff))) * 86_400_000;
}
throw new RangeError('No such Hijri date in this calendar');
}
hijriToGregorianIntl(1448, 9, 1).toISOString().slice(0, 10); // '2027-02-08'
hijriToGregorianIntl(1446, 9, 30); // throws: that Ramadan had 29 days
I checked this function against a table-based implementation on every valid Hijri date from 1356 to 1500 AH (51,383 dates, including every year boundary): all matched, using on average about 2 formatter calls per conversion and never more than 3. (Node 24.21 / ICU 78.3.)
Note the last line: Hijri months have 29 or 30 days, and which one varies by year (Ramadan was 30 days in 1445, 29 in 1446 and 30 in 1447). So validate Hijri input rather than assuming 30-day months.
Runtime support
The islamic-umalqura calendar comes from the runtime's ICU data. Node has shipped full ICU by default since version 13, and current browsers support the Islamic calendars through Intl, but older or stripped-down runtimes may not. Check new Intl.DateTimeFormat('en-u-ca-islamic-umalqura').resolvedOptions().calendar: if it doesn't say islamic-umalqura, you've silently fallen back to another calendar.
When a table beats Intl
The search above works, but a lookup table is better when:
-
You convert a lot. In a rough benchmark (Node 24.21 on a Windows laptop, the 51,383 dates above converted in a loop, averaged), the
Intlsearch took about 15 µs per date and a table lookup about 0.3 µs. Your numbers will differ; the gap is the point. - You need the same answer everywhere. A table gives identical results in every browser, Node version and runtime, independent of the ICU data each one ships.
- You need month lengths, validation or date differences without probing the formatter.
I maintain @kirnu/arabic-core, a zero-dependency TypeScript library that includes the Umm al-Qura table for 1343–1500 AH (2 August 1924 to 16 November 2077). It agrees with Intl's islamic-umalqura on every day from 1937 to 2077 that I checked:
import { gregorianToHijri, hijriToGregorian, hijriMonthLength, HIJRI_RANGE } from '@kirnu/arabic-core';
gregorianToHijri({ year: 2024, month: 3, day: 11 }); // { year: 1445, month: 9, day: 1 }
hijriToGregorian({ year: 1448, month: 9, day: 1 }); // { year: 2027, month: 2, day: 8 }
hijriMonthLength(1446, 9); // 29
// Dates outside HIJRI_RANGE throw a HijriRangeError instead of guessing.
It also does Hijri ages and date differences, and (the reason it exists) Arabic number-to-words. If you only need a quick answer rather than code, there's a free online Hijri ↔ Gregorian converter built on the same table (Arabic interface). The library's source and tests are at github.com/getkirnu/arabic-core.
If you've hit a Hijri-date bug that isn't covered here, I'd like to hear about it in the comments.
Top comments (1)
Such a well-structured and practical article. Thanks for documenting this and sharing with the community! 🚀