Format a Duration as Hours and Minutes

To turn a raw amount like 95 minutes into "1 hr 35 min" or "1:35", normalize it with Temporal.Duration.round({ largestUnit: 'hours' }), then render it with Intl.DurationFormat. Part of Intl.DurationFormat for Human-Readable Durations.

Why this scenario is tricky

The trap is a two-step problem masquerading as one. People reach straight for a formatter and pass { minutes: 95 } — and get "95 min" back, because Intl.DurationFormat renders exactly the units you give it. It performs no carry. The minutes-into-hours arithmetic is a separate normalization step you must do first.

Doing that carry by hand (Math.floor(95 / 60) and 95 % 60) works for hours and minutes but is fragile: it silently breaks the moment a value is negative, fractional, or you decide to include seconds, and it duplicates logic that Temporal.Duration.round() already does correctly. The robust pattern is: build a Temporal.Duration, round({ largestUnit: 'hours' }) to perform the carry, then hand the normalized duration to a cached formatter.

The normalization-then-format pipeline

From raw minutes to formatted hours and minutes95 minutes flows into Temporal.Duration.round with largestUnit hours, producing a normalized record of 1 hour 35 minutes, which Intl.DurationFormat renders as 1 hr 35 min or 1:35.{ minutes: 95 }raw input.round({ largestUnit:'hours' }) — carry{ hours: 1,minutes: 35 }1 hr 35 min1:35

Minimal working solution

import { Temporal } from '@js-temporal/polyfill';

// Carry minutes into hours, then format. 'hours' is the largest unit we want shown.
function hoursMinutes(totalMinutes: number, style: 'short' | 'digital' = 'short'): string {
  const normalized = Temporal.Duration
    .from({ minutes: totalMinutes })
    .round({ largestUnit: 'hours' }); // 95 -> { hours: 1, minutes: 35 }
  // 'short' -> '1 hr, 35 min'; 'digital' -> '1:35'.
  return new Intl.DurationFormat('en-US', { style }).format(normalized);
}

hoursMinutes(95);            // '1 hr, 35 min'
hoursMinutes(95, 'digital'); // '1:35'

Intl.DurationFormat is TC39 Stage 3, so on older runtimes guard for it and lazy-load the @formatjs/intl-durationformat polyfill before the first call — see the parent guide for the ensureDurationFormat() helper.

Full production version

import { Temporal } from '@js-temporal/polyfill';

type HmStyle = 'short' | 'long' | 'narrow' | 'digital';

const hmCache = new Map<string, Intl.DurationFormat>();

function hmFormatter(locale: string, style: HmStyle): Intl.DurationFormat {
  const key = `${locale}|${style}`;
  let f = hmCache.get(key);
  if (!f) {
    f = new Intl.DurationFormat(locale, {
      style,
      // For non-digital styles, drop a zero minutes so 120 min reads '2 hr', not '2 hr, 0 min'.
      minutesDisplay: style === 'digital' ? 'always' : 'auto',
    });
    hmCache.set(key, f);
  }
  return f;
}

export function formatHoursMinutes(
  totalMinutes: number,
  opts: { locale?: string; style?: HmStyle } = {},
): string {
  if (!Number.isFinite(totalMinutes)) {
    throw new TypeError('totalMinutes must be a finite number'); // guard NaN/Infinity early
  }
  const { locale = 'en-US', style = 'short' } = opts;

  // Round to whole minutes first so fractional inputs (e.g. 95.4) do not leak a fractional unit,
  // then carry into hours. A negative input keeps its sign and formats with a locale minus sign.
  const normalized = Temporal.Duration
    .from({ minutes: Math.trunc(totalMinutes) })
    .round({ largestUnit: 'hours', smallestUnit: 'minutes' });

  return hmFormatter(locale, style).format(normalized);
}

formatHoursMinutes(95);                          // '1 hr, 35 min'
formatHoursMinutes(120);                         // '2 hr'           (zero minutes dropped)
formatHoursMinutes(95, { style: 'digital' });    // '1:35'
formatHoursMinutes(-95);                         // '-1 hr, 35 min'

Verification snippet

import { Temporal } from '@js-temporal/polyfill';

// Confirms the carry happens BEFORE formatting and that edge values behave.
const round = (m: number) =>
  Temporal.Duration.from({ minutes: m }).round({ largestUnit: 'hours' });

// 95 minutes carries to exactly 1h 35m.
console.assert(round(95).hours === 1 && round(95).minutes === 35, 'carry 95 -> 1h35m');

// Exact hour leaves zero minutes (formatter then drops it with minutesDisplay:'auto').
console.assert(round(120).hours === 2 && round(120).minutes === 0, 'exact 120 -> 2h0m');

// Under an hour stays in minutes.
console.assert(round(35).hours === 0 && round(35).minutes === 35, 'under-hour 35 -> 0h35m');

// Negative durations preserve magnitude and carry the same way.
console.assert(round(-95).hours === -1 && round(-95).minutes === -35, 'negative -95');

// The string itself: digital style yields a clock.
console.assert(
  new Intl.DurationFormat('en-US', { style: 'digital' }).format(round(95)) === '1:35:00',
  'digital style renders clock',
);

Common pitfalls

// Wrong: no carry
new Intl.DurationFormat('en-US', { style: 'short' }).format({ minutes: 95 }); // '95 min'
// Right: round first
new Intl.DurationFormat('en-US', { style: 'short' })
  .format(Temporal.Duration.from({ minutes: 95 }).round({ largestUnit: 'hours' })); // '1 hr, 35 min'

FAQ

Why does { minutes: 95 } print "95 min" instead of "1 hr 35 min"?

Intl.DurationFormat never carries units — it renders what you pass. Normalize with Temporal.Duration.from({ minutes: 95 }).round({ largestUnit: 'hours' }) to get { hours: 1, minutes: 35 } before formatting.

How do I get the colon form "1:35" instead of "1 hr 35 min"?

Use style: 'digital'. Note the digital style always shows seconds, so a normalized 1h 35m renders as '1:35:00'; pass seconds: 0 is implicit, and the clock units stay visible by design.