Format
Locale-aware formatters for dates, ranges, currency, numbers, and durations.
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 pointNumbers
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"); // nullAnything it cannot fully account for returns null rather than a partial reading, so
parseDuration("1h bogus") fails instead of silently meaning one hour.
Reference
| Function | Takes | Returns |
|---|---|---|
formatDate | Date | number | string | Formatted date |
formatDateRange | Two dates | Collapsed range |
formatRelativeTime | A date, optional now | "3 days ago" |
formatNumber | number | Formatted number |
formatCurrency | number, currency | Formatted money |
formatPercent | number where 1 is 100% | "45.7%" |
formatCompactNumber | number | "1.2M" |
formatDuration | milliseconds | "2h 15m", "7:05" |
parseDuration | string | milliseconds, 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.