I18n Date Formatting
Try in playground →opens this recipe’s markup and SCSS, live-compiled
Live: switch locales and watch the same instant reformat — short, long, date+time, a fixed Tokyo timezone, and a relative-time phrase.
2026-09-10T14:30:00.000Z
Use this when
You're displaying a date or time to users in more than one locale and want it to read naturally in each one — "9/10/2026" for en-US, "10.9.2026" for de-DE, "2026年9月10日" for ja-JP — without hand-writing a formatter per locale or reaching for a library. The browser's Intl.DateTimeFormat already knows every locale's date order, separators, and month names; you supply the locale and a style, it supplies the string.
This recipe covers formatting a date you already have. Picking one is the datepicker recipe's job — the two pair naturally: capture the date with datepicker, display it back to the user through Intl.DateTimeFormat.
Structure (raw HTML)
No special markup — this is a JS formatting concern rendered into whatever element you'd already use for the date:
<time datetime="2026-09-10T14:30:00Z" data-cia-recipe="i18n-date-formatting">
<!-- formatted text goes here, e.g. "September 10, 2026" -->
</time>
Notes on the markup:
<time datetime="...">with a real ISO 8601 value is the machine-readable anchor — screen readers and browsers can announce or act on it even before your JS runs; the locale-formatted text inside is the human-readable presentation layer on top.- No
data-localeor similar attribute is needed on the element itself — the locale comes from wherever your app already tracks it (a route param, a cookie,navigator.language), not from markup.
Styling (cia mixins)
// DateDisplay.module.scss
@use 'css-is-awesome/api' as cia;
.date {
@include cia.font(medium, 2);
color: cia.color(text-secondary);
}
.relative {
color: cia.color(text-muted);
font-size: 0.85em;
}
Interactivity
Three Intl.DateTimeFormat styles cover almost everything:
const date = new Date("2026-09-10T14:30:00Z");
new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(date);
// en-US: "9/10/26" de-DE: "10.9.26" ja-JP: "2026/9/10"
new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date);
// en-US: "September 10, 2026" de-DE: "10. September 2026"
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date);
// en-US: "Sep 10, 2026, 2:30 PM"
A specific timezone (rather than the viewer's local one) is a plain option, not a separate API:
new Intl.DateTimeFormat(locale, { dateStyle: "long", timeStyle: "short", timeZone: "Asia/Tokyo" }).format(date);
Relative time ("3 days ago", "in 2 hours") is a different constructor — Intl.RelativeTimeFormat doesn't take a Date, it takes a signed number and a unit, so you compute the delta yourself:
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
rtf.format(-3, "day"); // en-US: "3 days ago" (numeric: "auto" prefers "yesterday" for -1)
rtf.format(2, "hour"); // en-US: "in 2 hours"
numeric: "auto" is almost always what you want — it substitutes idioms ("yesterday", "tomorrow") where the locale has one, falling back to the numeric phrasing otherwise.
A11y checklist
- The underlying
<time datetime="...">carries a real, machine-parseable ISO 8601 value even when the visible text is a relative phrase like "3 days ago" — a screen reader or translation tool can still recover the actual instant (WCAG 2.2 SC 1.3.1 Info and Relationships) -
Intl.RelativeTimeFormatoutput is re-computed (or paired with the absolute<time>value in a tooltip/title) rather than left static on a long-lived page — "3 days ago" silently becomes wrong as time passes if nothing ever refreshes it - The formatting locale matches the page's actual content language (
<html lang>), not left defaulted to the server's locale regardless of who's viewing — see the SSR pitfall below - Don't rebuild the month/weekday name lookup by hand for a locale — a hand-maintained translation table drifts from
Intl's and is one more thing to keep in sync (WCAG 2.2 SC 3.1.2 Language of Parts)
Framework examples
React
"use client";
import { useEffect, useState } from "react";
export default function DateDisplay({ date, locale }: { date: Date; locale: string }) {
// Format only after mount: the server's ICU default locale rarely matches
// the visiting browser's, so formatting during SSR produces text that
// doesn't match what the client re-renders — a hydration mismatch. Render
// the ISO value first (correct on both sides), swap to the formatted
// string once mounted.
const [text, setText] = useState(date.toISOString());
useEffect(() => {
setText(new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date));
}, [date, locale]);
return <time dateTime={date.toISOString()}>{text}</time>;
}
Vue
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
const props = defineProps<{ date: Date; locale: string }>();
const text = ref(props.date.toISOString());
function format() {
text.value = new Intl.DateTimeFormat(props.locale, { dateStyle: "long" }).format(props.date);
}
onMounted(format);
watch(() => [props.date, props.locale], format);
</script>
<template>
<time :datetime="date.toISOString()">{{ text }}</time>
</template>
Svelte
<script lang="ts">
export let date: Date;
export let locale: string;
let text = date.toISOString();
import { onMount } from "svelte";
function format() {
text = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date);
}
onMount(format);
$: date, locale, format();
</script>
<time datetime={date.toISOString()}>{text}</time>
Vanilla
function renderDate(el, date, locale) {
el.setAttribute("datetime", date.toISOString());
el.textContent = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date);
}
// No SSR concern in a plain script — it only ever runs in the browser.
document.querySelectorAll("time[data-i18n-date]").forEach((el) => {
renderDate(el, new Date(el.dataset.iso), document.documentElement.lang || "en");
});
Pitfalls
- SSR locale mismatch. If you format a date during server rendering, the server's default ICU locale (often just
en-USregardless of who's requesting the page) can disagree with the browser's actual locale, producing a React/Vue hydration warning — or worse, a flash of wrong-locale text. Either format only client-side after mount (shown above), or pass the locale explicitly all the way from a source both server and client agree on (a URL segment, a cookie read the same way in both places) — never rely on an environment's implicit default. toLocaleDateString()without a fixed locale argument. Callingdate.toLocaleDateString()with no arguments uses the runtime's default locale — the server's, not the visitor's, in SSR. Always pass the locale explicitly:date.toLocaleDateString(locale), or better, construct anIntl.DateTimeFormatinstance up front if you're formatting many dates (it's the same API, reused instead of reconstructed per call).- Formatting a stale relative time.
Intl.RelativeTimeFormatoutput is a snapshot — "2 minutes ago" doesn't update itself. If it needs to stay current on a long-lived page (a live feed, a chat), re-run the format on an interval or pair it with the absolute time in atitleattribute so the number is never the only source of truth.
Related recipes
datepicker— capture the date this recipe displaysrtl-layout— layout direction is a separate concern from locale formatting; numbers and dates read left-to-right within themselves even inside RTL text (see rtl-layout's Pitfalls)i18n-number-currency,i18n-pluralization— the other twoIntl-based formatting recipes