Show Yesterday, Today, and Tomorrow Labels in JavaScript

To show word labels like 'yesterday' and 'tomorrow', use Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) and pass the day difference computed in the user's zone. Part of Intl.RelativeTimeFormat for Relative Dates.

Why this scenario is tricky

"Yesterday / today / tomorrow" labels look trivial and hide two real subtleties. The first is that "today" is a calendar-day concept anchored in a specific time zone, not an elapsed-hours concept: two timestamps 20 hours apart can be on different calendar days, and two 30 hours apart can be on the same one, depending on where midnight falls. So the day difference must be computed on civil dates in the user's zone, or the label is wrong for users near midnight or far from the server. The second subtlety is localization: "yesterday" is a special word in every language, and hard-coding the three English strings does not scale.

Intl.RelativeTimeFormat with numeric: 'auto' solves the localization half elegantly — it returns the idiomatic word ("yesterday", "today", "tomorrow", or the localized equivalent) for small offsets and falls back to "in 3 days" style for larger ones. Temporal solves the calendar half by computing the day difference between two PlainDates in the user's zone. Composing them gives correct, localized relative-day labels; the trick is to compute the difference civilly and let Intl choose the words.

Two things make this deceptively easy to break: the day difference must be counted in calendar days in the user's zone, not by dividing a millisecond gap (which flips at the wrong moment), and the friendly words only appear when you set numeric: 'auto' — the default 'always' prints '1 day ago' instead of 'yesterday'.

'auto' unlocks the wordsDefault numeric prints '1 day ago''auto' unlocks the wordsnumeric default ('always')'in 1 day', '1 day ago'numeric: 'auto''tomorrow', 'yesterday'Set numeric to 'auto' and count whole calendar days in the user's zone.

Minimal working solution

Computing today.until(target, { largestUnit: 'day' }).days between two PlainDates (both derived in the user's zone) gives an integer day offset — ‑1 for yesterday, 0 for today, 1 for tomorrow — and rtf.format(days, 'day') with a numeric: 'auto' formatter turns that into the idiomatic word. The numeric: 'auto' option is what makes ‑1 render as "yesterday" rather than "1 day ago"; with numeric: 'always' you would always get the numeric form. Because the offset is a plain integer, the same call transparently handles ‑2 ("2 days ago") and beyond, so the label degrades gracefully past the special-word range.

The essential detail is that both dates are civil dates in the user's zone. Deriving "today" as Temporal.Now.plainDateISO(userZone) and the target as a PlainDate in the same zone ensures the day count reflects the user's calendar, so a message sent at 11pm shows "today" to the sender and "yesterday" the next morning, matching intuition. Computing the difference on instants or in the wrong zone is what produces the classic "it says tomorrow but it's today" bug near midnight.

import { Temporal } from '@js-temporal/polyfill';
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const days = today.until(target, { largestUnit: 'day' }).days; // both PlainDate in user's zone
rtf.format(days, 'day'); // -1 -> 'yesterday', 0 -> 'today', 1 -> 'tomorrow'

Day diff to wordsSigned day count, numeric autoDay diff to wordstoday, targetuntil .daysrtf.format(days,'day')

Full production version

A production helper takes the target date/instant and the user's zone, reduces both endpoints to PlainDate in that zone, computes the day offset, and chooses between a relative word (for small offsets) and an absolute formatted date (for larger ones) — because "in 47 days" is less useful than "March 12" past a certain range. Deciding that threshold is a product choice: many UIs show relative labels within a week and switch to an absolute date beyond it. Keeping the threshold explicit means the label stays meaningful across the whole range rather than emitting unwieldy relative phrases.

Caching the Intl.RelativeTimeFormat per locale avoids reconstructing it on every render, which matters in a list of many timestamped items. And because the day computation is zone-anchored and the wording is delegated to Intl, the same helper serves every locale and every user zone without special cases. Pair it with the sibling "time ago" logic for sub-day granularity (minutes/hours) when the item is recent, so a single relative-time component covers "3 minutes ago" through "yesterday" through "March 12" coherently.

import { Temporal } from '@js-temporal/polyfill';
function dayLabel(target: Temporal.PlainDate, zone: string, locale = 'en'): string {
  const today = Temporal.Now.plainDateISO(zone);
  const days = today.until(target, { largestUnit: 'day' }).days;
  return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day');
}

Zoned label helperCompute today in the zone, then formatZoned label helpertarget,zonetoday in zoneuntil .dayslabel

Verification snippet

Yesterday/Today/Tomorrow assertionsKey cases assert correctlyAssertions that prove the edge casedays == -1'yesterday'days == 0'today'days == 1'tomorrow'days == -3'3 days ago'

Common pitfalls

The first pitfall is computing the day difference from elapsed hours or on instants rather than on civil dates in the user's zone, which mislabels items near midnight — the exact case the feature exists to get right. Reduce both endpoints to PlainDate in the user's zone first. The second is hard-coding "yesterday/today/tomorrow" as English strings, which does not localize; use Intl.RelativeTimeFormat with numeric: 'auto' so every language gets its idiomatic word. The third is using numeric: 'always', which suppresses the special words and always prints "1 day ago" — the opposite of what this feature wants.

A fourth pitfall is emitting unbounded relative phrases like "in 300 days", which are less legible than an absolute date; switch to a formatted date beyond a chosen threshold. A fifth is reconstructing the formatter on every item in a long list; cache it per locale. Finally, be deliberate about the user's zone source — the browser's reported zone is usually right, but for a server-rendered label you must pass the user's zone explicitly rather than using the server's, or every label is computed against the wrong midnight.

Yesterday/Today/Tomorrow pitfallsCommon mistakes and their fixesWrongRightdivide ms by 86.4Mflips at wrong instantuntil(...).days in zonecalendar-correctnumeric default'1 day ago' not 'yesterday'numeric: 'auto'friendly words

Frequently Asked Questions

How do I show 'yesterday' and 'tomorrow' instead of '1 day ago'?

Create Intl.RelativeTimeFormat with numeric: 'auto' and pass the signed day difference. With 'auto', values of -1, 0, and 1 render as 'yesterday', 'today', and 'tomorrow'; the default 'always' would print '1 day ago' and 'in 1 day'.

How do I compute the day difference correctly?

Count whole calendar days in the user's time zone, for example with Temporal.PlainDate.until(target, { largestUnit: 'day' }).days where both dates are PlainDates in that zone. Dividing a millisecond gap by 86,400,000 flips at the wrong moment near midnight.

How do I show yesterday, today, and tomorrow labels in JavaScript?

Compute the day offset between two PlainDate values in the user's zone with today.until(target, { largestUnit: 'day' }).days, then render it with an Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day'). numeric: 'auto' turns -1 into 'yesterday', 0 into 'today', and 1 into 'tomorrow' in the correct language, and falls back to 'in 3 days' style for larger offsets.

Why must the day difference be computed in the user's time zone?

Because 'today' is a calendar-day concept anchored to where midnight falls, not an elapsed-hours one. Two timestamps 20 hours apart can be on different calendar days, and 30 hours apart on the same day, depending on the zone. Reducing both endpoints to PlainDate in the user's zone makes the label match the user's calendar, so a message sent at 11pm reads 'today' then 'yesterday' the next morning.

What does numeric: 'auto' do in Intl.RelativeTimeFormat?

It lets the formatter use idiomatic special words — 'yesterday', 'today', 'tomorrow' and their localized equivalents — for small offsets, instead of always printing the numeric form. With numeric: 'always' you would get '1 day ago' and 'in 1 day' instead. For yesterday/today/tomorrow labels you want 'auto' so each language produces its natural word.