All posts

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.

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:

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:

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

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
}
Live demoWeek start by localeUse the keyboard

Loading demo…

Pick a locale and watch the first day of the week move.

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:

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.