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:

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.

One Gregorian date across Islamic calendar variantsThe same Gregorian instant resolves to Hijri dates that can differ by a day between the Umm al-Qura, tabular civil, and tabular astronomical variants.Gregorian2024-03-11islamic-umalqura β†’ Ramadan 1, 1445islamic (civil) β†’ Ramadan 2, 1445islamic-tbla β†’ Ramadan 1, 1445variants can differ by Β±1 day

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

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.