# Not every week starts on Sunday: calendar grids with Intl.Locale

> Building a calendar in React and TypeScript with native dates. How to compute the leading offset from the user's locale with getWeekInfo(), plus a fallback.

- Pool Ortega, September 22, 2026
- URL: https://www.poolortega.com/en/blog/calendar-week-start-intl-locale
- Tags: javascript, typescript, react, i18n

I built a calendar from scratch recently. React and TypeScript, no calendar UI libraries, just native JavaScript dates.

It was a good refresher on date details that are easy to forget once you stop dealing with them every day. The one I want to write about is small, and it breaks a lot of calendars: **the week doesn't start on the same day everywhere.**

## Sunday or Monday?

In the US, the week starts on Sunday. In Spain, Germany, the UK or Argentina, it starts on Monday. In Egypt or Saudi Arabia, it can be Saturday or Sunday.

It isn't even consistent inside one language. With the CLDR data that browsers use, `es-ES` starts on Monday, but `es-MX` and `es-CO` start on Sunday. Same language, different grid.

If you hardcode one convention, your calendar works for you and breaks the moment someone with a different locale opens it. The days line up under the wrong headers, or the first row has the wrong number of empty cells.

## The math that depends on it

A month grid has 7 columns. Before day 1, you render some empty cells so day 1 lands under the right weekday. That count is the **leading offset**.

`Date#getDay()` gives you the weekday of the 1st, from `0` (Sunday) to `6` (Saturday). If the week starts on Sunday, the offset is just that number. If it starts on Monday, everything shifts by one.

The general version:

```ts
function getLeadingOffset(year: number, month: number, firstDayOfWeek: number) {
  // firstDayOfWeek uses the same scale as getDay(): 0 = Sunday ... 6 = Saturday
  const firstOfMonth = new Date(year, month, 1).getDay();
  return (firstOfMonth - firstDayOfWeek + 7) % 7;
}
```

October 1, 2026 is a Thursday (`getDay()` is `4`). For `en-US` the offset is 4. For `es-ES` it's 3. Same month, different grid.

So the only real question is: where does `firstDayOfWeek` come from?

## Intl.Locale knows

The native API I hadn't touched in a while was `Intl.Locale.prototype.getWeekInfo()`. It tells you the week conventions for a locale:

```ts
new Intl.Locale("en-US").getWeekInfo();
// { firstDay: 7, weekend: [6, 7], minimalDays: 1 }

new Intl.Locale("es-ES").getWeekInfo();
// { firstDay: 1, weekend: [6, 7], minimalDays: 4 }
```

Careful with the numbers. `firstDay` goes from `1` (Monday) to `7` (Sunday), ISO style. `getDay()` goes from `0` (Sunday) to `6` (Saturday). They don't match, but `firstDay % 7` converts one to the other: 7 becomes 0, everything else stays.

There's one more catch. This API first shipped as a **getter**, `locale.weekInfo`, and was later changed to a **method**, `locale.getWeekInfo()`. Per MDN's compatibility data, the method is in Chrome 130+, Safari 17+, Firefox 153+ and Node 24+, and it reached Baseline in 2026. Older Chromium, Safari and Node versions (Node 18 to 22) only have the getter. Firefox didn't have either one before 153.

So in practice you check for both, and have a fallback for when neither exists.

## A version that works everywhere

```ts
type WeekInfo = { firstDay: number; weekend: number[]; minimalDays: number };

type LocaleWithWeekInfo = Intl.Locale & {
  getWeekInfo?: () => WeekInfo;
  weekInfo?: WeekInfo;
};

// Snapshot of CLDR regions whose week doesn't start on Monday.
const SUNDAY_FIRST = new Set(
  "AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW".split(" ")
);
const SATURDAY_FIRST = new Set(
  "AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY".split(" ")
);

export function getFirstDayOfWeek(tag: string): number {
  const locale = new Intl.Locale(tag) as LocaleWithWeekInfo;

  const info =
    typeof locale.getWeekInfo === "function"
      ? locale.getWeekInfo()
      : locale.weekInfo;

  if (info) return info.firstDay % 7; // 1–7 (Mon–Sun) → 0–6 (Sun–Sat)

  // Fallback: guess from the region ("es" → "es-Latn-ES" → "ES")
  const region = locale.region ?? locale.maximize().region;
  if (region && SUNDAY_FIRST.has(region)) return 0;
  if (region && SATURDAY_FIRST.has(region)) return 6;
  return 1; // CLDR's world default is Monday
}
```

The fallback list is a snapshot of CLDR data, read through `Intl.Locale` itself. It's not something you need to keep perfect. It only runs in browsers that don't have the API, and it gets the big ones right.

Then you pass the user's locale in. In the browser, `navigator.language` is a good start. If your app already has a locale in the URL, like `/es` or `/en`, use that instead so the grid matches the rest of the page. Keep in mind that a bare `es` maximizes to Spain, so if your audience is in Mexico, `es-MX` gives a different answer.

## The weekday headers have to move too

If the grid starts on Monday, the header row must start on Monday as well. `Intl.DateTimeFormat` gives you localized names without a translation file:

```ts
export function getWeekdayLabels(tag: string, firstDayOfWeek: number) {
  const format = new Intl.DateTimeFormat(tag, { weekday: "short" });
  // January 4, 2026 is a Sunday, so day 4 + n is weekday n
  return Array.from({ length: 7 }, (_, i) =>
    format.format(new Date(2026, 0, 4 + ((firstDayOfWeek + i) % 7)))
  );
}

getWeekdayLabels("es-ES", 1); // ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"]
```

Grid offset and headers come from the same `firstDayOfWeek`, so they can't get out of sync.

## What I'd keep in mind

- Never assume Sunday or Monday. Ask the locale.
- `firstDay` is 1–7, `getDay()` is 0–6. Convert once, in one place.
- Feature-detect `getWeekInfo()` and `weekInfo`. Both exist in the wild.
- `getWeekInfo()` also returns `weekend`, which is useful if you want to style weekend columns, and that varies by region too.

Native date APIs get a bad reputation, some of it deserved. But `Intl` does a lot of this work for free, and it's worth checking before you reach for a library or hardcode a convention.
