mwui
GitHub

Format

Locale-aware formatters for dates, ranges, currency, numbers, and durations.

formatDateAug 21, 2026
formatDateRangeAug 21 – 26, 2026
formatRelativeTime3 days ago
formatCurrency€1,234.50
formatCompactNumber1.2M
formatPercent45.7%
formatDuration2h 15m
formatDuration · clock7:05
parseDuration("1h 30m")5400000 ms

Installation

Usage

A small set of formatters built entirely on the Intl APIs — no dependencies, no bundled locale data, no timezone database to keep current. Every function takes an optional locale, and falls back to the runtime's locale when you omit it.

import { formatCurrency, formatDate, formatDateRange, formatRelativeTime } from "@/lib/format";

formatDate("2025-03-04"); // "Mar 4, 2025"
formatDateRange("2025-03-04", "2025-03-09"); // "Mar 4 – 9, 2025"
formatRelativeTime(Date.now() - 3 * 86_400_000); // "3 days ago"
formatCurrency(1234.5, { currency: "EUR" }); // "€1,234.50"

Constructing an Intl formatter is expensive relative to using one, so every formatter is cached by locale and options. Calling these in a table cell or a render loop is fine.

Dates

formatDate defaults to a medium date style, and forwards any Intl.DateTimeFormatOptions:

formatDate(order.placedAt, { dateStyle: "full" }); // "Tuesday, March 4, 2025"
formatDate(order.placedAt, { month: "short", day: "numeric" }); // "Mar 4"
formatDate(order.placedAt, { timeZone: "UTC" }); // "Mar 4, 2025"

Options that only configure the formatter, such as timeZone, keep the default style. Options that select date parts replace it.

formatDateRange uses Intl.DateTimeFormat.formatRange, which collapses whatever the two dates share:

formatDateRange("2025-03-04", "2025-03-09"); // "Mar 4 – 9, 2025"
formatDateRange("2024-12-30", "2025-03-09"); // "Dec 30, 2024 – Mar 9, 2025"

formatRelativeTime picks the largest unit that fits and reads naturally at the boundaries:

formatRelativeTime(date); // "yesterday", "in 2 hours", "3 days ago", "now"
formatRelativeTime(date, { numeric: "always" }); // "1 day ago"
formatRelativeTime(date, { now: reportGeneratedAt }); // relative to a fixed point

Numbers

formatNumber(1234.5678, { maximumFractionDigits: 2 }); // "1,234.57"
formatCurrency(1234.5, { currency: "EUR" }); // "€1,234.50"
formatPercent(0.4567); // "45.7%"
formatCompactNumber(1_234_567); // "1.2M"

formatCurrency defaults to USD. Wrap it once in your app if you have a single currency:

export const money = (value: number) => formatCurrency(value, { currency: "EUR", locale: "pt-PT" });

Durations

Durations are milliseconds in, so they compose with plain date arithmetic.

formatDuration(8_100_000); // "2h 15m"
formatDuration(8_100_000, { style: "long" }); // "2 hours, 15 minutes"
formatDuration(25_500_000, { style: "clock" }); // "7:05"

clock is the timesheet format: hours are never rolled into days, so a 20-hour total reads 20:00 rather than something a reader has to convert. parseDuration reads the formats people actually type and returns milliseconds, or null when the input is not a duration:

parseDuration("1h 30m"); // 5400000
parseDuration("1.5h"); // 5400000
parseDuration("1:30"); // 5400000
parseDuration("90"); // 5400000 — a bare number is minutes
parseDuration("tomorrow"); // null

Anything it cannot fully account for returns null rather than a partial reading, so parseDuration("1h bogus") fails instead of silently meaning one hour.

Reference

FunctionTakesReturns
formatDateDate | number | stringFormatted date
formatDateRangeTwo datesCollapsed range
formatRelativeTimeA date, optional now"3 days ago"
formatNumbernumberFormatted number
formatCurrencynumber, currencyFormatted money
formatPercentnumber where 1 is 100%"45.7%"
formatCompactNumbernumber"1.2M"
formatDurationmilliseconds"2h 15m", "7:05"
parseDurationstringmilliseconds, or null

Invalid dates and non-finite numbers format to an empty string rather than "Invalid Date" or "NaN", so a missing field renders as a blank cell.