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
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
The production shape of "hours and minutes" is a two-stage pipeline: normalize the raw duration so minutes carry into hours (100 minutes becomes 1h40m), then format the normalized value. Temporal's Duration.round with largestUnit: 'hour' does the carrying correctly, and Intl.DurationFormat renders the result in the chosen style — 'short' for "1 hr 40 min" or 'digital' for a "1:40" clock-style readout. Separating normalization from formatting keeps each step simple and lets you change the display style without touching the arithmetic, and it avoids the manual Math.floor(total/60) / total%60 pair that is easy to get subtly wrong (negative durations, zero-padding).
Because durations here are fixed-unit (hours and minutes), no relativeTo anchor is needed — the carrying is unambiguous, since sixty minutes is always an hour regardless of the calendar. That is what makes this case simpler than durations involving days or months. Choose the style to match the slot: 'digital' for a compact timer or media-player readout, 'short'/'long' for prose. Cache the Intl.DurationFormat per locale and style, since a UI that shows many durations (a list of task times, a set of video lengths) would otherwise pay repeated construction cost.
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
The first pitfall is hand-rolling the carry with Math.floor(totalMinutes / 60) and totalMinutes % 60, which works for the happy path but mishandles negative durations and invites zero-padding bugs; let Temporal normalize and Intl.DurationFormat render. The second is formatting without normalizing first, so a value like "100 minutes" displays as "100 min" instead of "1 hr 40 min"; round to largestUnit: 'hour' before formatting. The third is assuming Intl.DurationFormat is universally available — it is relatively new, so verify support in your target runtimes and provide a fallback.
A fourth pitfall is mixing the digital and worded styles inconsistently across a UI, so some durations read "1:40" and others "1 hr 40 min"; pick one style per context and apply it uniformly. A fifth is forgetting that hours-and-minutes is a fixed-unit case and needlessly reaching for a relativeTo anchor, which is only required when calendar units (days, months, years) are involved. Keeping the pipeline to normalize-then-format, with the right style and a cached formatter, covers the common cases cleanly.
- Skipping normalization.
new Intl.DurationFormat('en-US').format({ minutes: 95 })returns'95 min', not'1 hr 35 min'.
// 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'
- Hand-rolling the carry.
Math.floor(95/60)works until the value is fractional or negative. Letround()own it. - Hard-coding
"hr"/"min"labels. That is English-only; letstyle+ locale produce labels and the correct list conjunction. - Re-creating the formatter per call. Construction is expensive; cache per
locale+style.
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.
How do I format a duration as hours and minutes?
Normalize first so minutes carry into hours — Temporal's Duration.round({ largestUnit: 'hour' }) turns 100 minutes into 1h40m — then render with Intl.DurationFormat: style 'short' gives '1 hr 40 min' and 'digital' gives '1:40'. Keeping normalization and formatting as separate steps avoids the error-prone manual Math.floor(total/60) and total%60 pair and lets you change the display style without touching the arithmetic.
Do I need a relativeTo anchor to format hours and minutes?
No. Hours and minutes are fixed-length units — sixty minutes is always one hour regardless of the calendar — so the carry is unambiguous and no anchor is needed. relativeTo is only required when a duration involves calendar units like days, months, or years, whose lengths vary with the date. That is why the hours-and-minutes case is simpler than durations that span days.
What is the difference between the short and digital duration styles?
'short' renders worded, abbreviated output like '1 hr 40 min', suitable for prose and labels, while 'digital' renders a clock-style '1:40', suitable for timers and media readouts. Both format the same normalized Temporal.Duration; the choice is purely presentational. Pick one per context and apply it consistently so durations do not read inconsistently across the UI.