Format a Countdown as Days, Hours, Minutes in JavaScript

To format a countdown, balance the remaining Temporal.Duration with round, then render it with Intl.DurationFormat for localized unit words. Part of Intl.DurationFormat for Human-Readable Durations.

Why this scenario is tricky

A "2 days, 3 hours, 5 minutes" countdown has two separable hard parts, and doing them in one step is where hand-rolled versions go wrong. The first is computing a balanced duration — turning the raw gap between now and the target into whole days, hours, and minutes that add up correctly — and the second is rendering that duration as localized, pluralized text. Cram both into one function with manual modulo math and string concatenation, and you get code that is off by one at unit boundaries and wrong in every language but English, because "2 days" is not how most locales pluralize or order the units.

The modern approach keeps the two concerns separate and lets a purpose-built API handle each. Temporal computes and rounds the balanced duration, and Intl.DurationFormat renders it with correct pluralization and locale-appropriate unit names and ordering. Neither step requires you to know how Polish pluralizes "hours" or whether a locale writes the day or the hour first — that knowledge lives in the platform's internationalization data. The trick is to compose these two tools rather than reinvent either.

A countdown display couples two concerns: computing the balanced remaining duration (days, hours, minutes) and rendering it in the user's language. Manual string building handles neither pluralization nor locale word order; Intl.DurationFormat does both, and Temporal.Duration.round supplies the balanced record it formats.

Balance then localizeManual strings miss plurals and localeBalance then localize${d}d ${h}h ${m}mnot localized, no pluralsIntl.DurationFormat(record)'2 days, 3 hr, 5 min'round balances the units; DurationFormat renders them per locale.

Minimal working solution

Computing the remainder with target.since(Temporal.Now.instant(), { largestUnit: 'day' }) gives a Duration capped at days, and .round({ largestUnit: 'day', smallestUnit: 'minute' }) trims it to whole minutes so the display does not flicker with seconds. Passing that to new Intl.DurationFormat('en', { style: 'short' }).format(rem) renders "2 days, 3 hr, 5 min" — already pluralized and ordered for the locale. Swapping the locale tag changes the language and the unit conventions without any change to the computation, which is the payoff of separating the two steps.

The style option controls verbosity: 'long' produces "2 days, 3 hours, 5 minutes", 'short' the abbreviated form, and 'narrow' the most compact. Choosing largestUnit: 'day' and smallestUnit: 'minute' frames the countdown at the granularity users expect for a multi-day counter; a countdown to something imminent might use hour and second instead. The point is that the granularity is an explicit rounding decision, not an accident of how you happened to write the modulo chain.

import { Temporal } from '@js-temporal/polyfill';
const rem = target.since(Temporal.Now.instant(), { largestUnit: 'day' })
  .round({ largestUnit: 'day', smallestUnit: 'minute' });
new Intl.DurationFormat('en', { style: 'short' }).format(rem); // '2 days, 3 hr, 5 min'

Duration to textBalance, then DurationFormatDuration to textremaininground balanceDurationFormat

Full production version

In production the countdown is live and needs two refinements: a past-due guard and a chosen re-render cadence. Comparing the balanced duration against zero (with Temporal.Duration.compare) lets you switch to a "started" or "expired" label the moment the target passes, rather than rendering a negative "‑1 min". And because the smallest displayed unit is minutes, you only need to re-render each minute, not each second — schedule the next tick to the next minute boundary to avoid needless work while keeping the display honest.

Because rendering is delegated to Intl.DurationFormat, adding locales is free: the same balanced Duration renders correctly in every supported language, so a multilingual app does not maintain per-language templates. Keep the computation in one place (produce a balanced, rounded Duration), keep the formatting in another (hand it to a cached DurationFormat per locale), and the countdown stays correct, localized, and cheap. Cache the formatter instances by locale and style, since constructing them repeatedly on every tick is wasteful.

import { Temporal } from '@js-temporal/polyfill';
function countdownText(target: Temporal.Instant, locale = 'en'): string {
  const rem = target.since(Temporal.Now.instant(), { largestUnit: 'day', smallestUnit: 'minute' });
  if (Temporal.Duration.compare(rem, {}) <= 0) return 'now';
  return new Intl.DurationFormat(locale, { style: 'short' }).format(rem);
}

Countdown helperClamp when elapsed, else localizeCountdown helpertargetsince balanceelapsed?'now'/text

Verification snippet

Format a Countdown assertionsKey cases assert correctlyAssertions that prove the edge case2d 3h 5m'2 days, 3 hr, 5 min'elapsed'now'de-DEGerman wordsstyle long'hours'

Common pitfalls

A final consideration is accessibility and clarity: an abbreviated 'short' style like '2 days, 3 hr, 5 min' is compact but a screen reader may voice the abbreviations awkwardly, so consider the 'long' style for assistive contexts or provide an aria-label with the fully spelled-out duration. Whichever style you show visually, the underlying balanced Temporal.Duration is the same, so producing a second, long-form rendering for accessibility is just one more format call on the value you already computed.

The first pitfall is hand-rolling the unit breakdown with modulo arithmetic, which slips at boundaries and produces English-only, incorrectly-pluralized output. Let Temporal balance and Intl.DurationFormat render. The second is formatting seconds you then never update, causing either a flickering or a stale display; round to your smallest shown unit and re-render on that unit's boundary. The third is failing to guard the past-due case, so the countdown shows negative components after the target passes; compare against zero and switch to a done state.

A fourth pitfall is constructing a new Intl.DurationFormat on every tick, which is measurable overhead in a counter that fires frequently; build it once per locale/style and reuse it. Finally, remember that Intl.DurationFormat is relatively new — verify support in your target runtimes and provide a fallback (a simpler formatter, or the polyfill) for environments that lack it, so the countdown degrades gracefully rather than throwing.

Format a Countdown pitfallsCommon mistakes and their fixesWrongRighttemplate literal unitsno plural/localeIntl.DurationFormatlocalized unitsdisplay unbalanced record'75 min'round to balance firstreads naturally

Frequently Asked Questions

How do I format a countdown as days, hours, and minutes?

Compute the remaining time as a Temporal.Duration with target.since(now), balance it with round({ largestUnit: 'day', smallestUnit: 'minute' }), and pass it to Intl.DurationFormat. That yields a localized string like '2 days, 3 hr, 5 min' with correct plurals.

Why use Intl.DurationFormat instead of building the string myself?

Manual strings hard-code English unit words and miss pluralization and locale ordering. Intl.DurationFormat renders the balanced duration record in the user's language, and pairing it with Temporal.Duration.round keeps the numbers balanced.

How do I format a countdown as days, hours, and minutes?

Compute the remainder with target.since(Temporal.Now.instant(), { largestUnit: 'day' }), round it to whole minutes, and render it with new Intl.DurationFormat(locale, { style: 'short' }).format(rem), which yields '2 days, 3 hr, 5 min' correctly pluralized and ordered for the locale. Keep the Temporal computation and the Intl formatting as separate steps so changing the language needs no change to the math.

How do I stop a countdown from showing negative time after it ends?

Compare the balanced duration against zero with Temporal.Duration.compare each tick; when it reaches zero or below, switch to a 'started' or 'expired' label instead of rendering negative components. Because the smallest shown unit is minutes, you also only need to re-render on each minute boundary rather than every second, which keeps the counter cheap.

Why use Intl.DurationFormat instead of building the string myself?

Because it handles pluralization, unit names, and unit ordering per locale — knowledge that differs by language and is easy to get wrong by hand. A manual modulo-and-concatenate approach is English-only and slips at unit boundaries. Compute a balanced Temporal.Duration and hand it to a cached Intl.DurationFormat, and the same code renders correctly in every supported language.