# Format

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

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/format.json
```

[Registry JSON](https://mwui.vercel.app/r/format.json)

## Preview

```tsx
import * as React from "react";

import {
  formatCompactNumber,
  formatCurrency,
  formatDate,
  formatDateRange,
  formatDuration,
  formatPercent,
  formatRelativeTime,
  parseDuration,
} from "@/lib/format";

const DAY = 86_400_000;

export function Preview() {
  const now = React.useMemo(() => Date.now(), []);

  const rows = [
    { label: "formatDate", value: formatDate(now) },
    { label: "formatDateRange", value: formatDateRange(now, now + 5 * DAY) },
    { label: "formatRelativeTime", value: formatRelativeTime(now - 3 * DAY) },
    { label: "formatCurrency", value: formatCurrency(1234.5, { currency: "EUR" }) },
    { label: "formatCompactNumber", value: formatCompactNumber(1_234_567) },
    { label: "formatPercent", value: formatPercent(0.4567) },
    { label: "formatDuration", value: formatDuration(8_100_000) },
    { label: "formatDuration · clock", value: formatDuration(25_500_000, { style: "clock" }) },
    { label: 'parseDuration("1h 30m")', value: `${String(parseDuration("1h 30m"))} ms` },
  ];

  return (
    <div className="flex w-full max-w-sm flex-col gap-1.5">
      {rows.map((row) => (
        <div key={row.label} className="flex items-baseline justify-between gap-4 text-sm">
          <span className="font-mono text-xs text-muted-foreground">{row.label}</span>
          <span className="text-right tabular-nums">{row.value}</span>
        </div>
      ))}
    </div>
  );
}
```


## Source

### lib/format.ts

```ts
type DateInput = Date | number | string;

type LocaleOption = {
  locale?: Intl.LocalesArgument;
};

type FormatDateOptions = LocaleOption & Intl.DateTimeFormatOptions;

type FormatNumberOptions = LocaleOption & Intl.NumberFormatOptions;

type FormatCurrencyOptions = FormatNumberOptions & {
  currency?: string;
};

type FormatRelativeTimeOptions = LocaleOption & {
  now?: DateInput;
  numeric?: Intl.RelativeTimeFormatNumeric;
};

type DurationStyle = "short" | "long" | "clock";

type FormatDurationOptions = LocaleOption & {
  style?: DurationStyle;
};

const MS_PER_SECOND = 1000;
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;

const RELATIVE_UNITS: [unit: Intl.RelativeTimeFormatUnit, milliseconds: number][] = [
  ["year", 365 * MS_PER_DAY],
  ["month", 30 * MS_PER_DAY],
  ["week", 7 * MS_PER_DAY],
  ["day", MS_PER_DAY],
  ["hour", MS_PER_HOUR],
  ["minute", MS_PER_MINUTE],
  ["second", MS_PER_SECOND],
];

const DURATION_UNIT_MILLISECONDS: Record<string, number> = {
  d: MS_PER_DAY,
  day: MS_PER_DAY,
  days: MS_PER_DAY,
  h: MS_PER_HOUR,
  hr: MS_PER_HOUR,
  hrs: MS_PER_HOUR,
  hour: MS_PER_HOUR,
  hours: MS_PER_HOUR,
  m: MS_PER_MINUTE,
  min: MS_PER_MINUTE,
  mins: MS_PER_MINUTE,
  minute: MS_PER_MINUTE,
  minutes: MS_PER_MINUTE,
  s: MS_PER_SECOND,
  sec: MS_PER_SECOND,
  secs: MS_PER_SECOND,
  second: MS_PER_SECOND,
  seconds: MS_PER_SECOND,
};

const DATE_PART_OPTIONS = [
  "dateStyle",
  "timeStyle",
  "weekday",
  "era",
  "year",
  "month",
  "day",
  "dayPeriod",
  "hour",
  "minute",
  "second",
  "fractionalSecondDigits",
  "timeZoneName",
] as const satisfies readonly (keyof Intl.DateTimeFormatOptions)[];

const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
const numberFormatters = new Map<string, Intl.NumberFormat>();
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();

function getDateTimeFormatter(locale: Intl.LocalesArgument, options: Intl.DateTimeFormatOptions) {
  const key = JSON.stringify([locale, options]);
  let formatter = dateTimeFormatters.get(key);

  if (!formatter) {
    formatter = new Intl.DateTimeFormat(locale, options);
    dateTimeFormatters.set(key, formatter);
  }

  return formatter;
}

function getNumberFormatter(locale: Intl.LocalesArgument, options: Intl.NumberFormatOptions) {
  const key = JSON.stringify([locale, options]);
  let formatter = numberFormatters.get(key);

  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, options);
    numberFormatters.set(key, formatter);
  }

  return formatter;
}

function getRelativeTimeFormatter(
  locale: Intl.LocalesArgument,
  options: Intl.RelativeTimeFormatOptions,
) {
  const key = JSON.stringify([locale, options]);
  let formatter = relativeTimeFormatters.get(key);

  if (!formatter) {
    formatter = new Intl.RelativeTimeFormat(locale, options);
    relativeTimeFormatters.set(key, formatter);
  }

  return formatter;
}

function withDefaultDateStyle(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions {
  const hasDatePart = DATE_PART_OPTIONS.some((option) => options[option] !== undefined);
  return hasDatePart ? options : { ...options, dateStyle: "medium" };
}

function toDate(value: DateInput): Date {
  return value instanceof Date ? value : new Date(value);
}

function isValidDate(value: Date): boolean {
  return !Number.isNaN(value.getTime());
}

function formatDate(value: DateInput, { locale, ...options }: FormatDateOptions = {}): string {
  const date = toDate(value);
  if (!isValidDate(date)) return "";

  return getDateTimeFormatter(locale, withDefaultDateStyle(options)).format(date);
}

function formatDateRange(
  start: DateInput,
  end: DateInput,
  { locale, ...options }: FormatDateOptions = {},
): string {
  const startDate = toDate(start);
  const endDate = toDate(end);

  if (!isValidDate(startDate) || !isValidDate(endDate)) return "";

  return getDateTimeFormatter(locale, withDefaultDateStyle(options)).formatRange(
    startDate,
    endDate,
  );
}

function formatRelativeTime(
  value: DateInput,
  { locale, now = Date.now(), numeric = "auto" }: FormatRelativeTimeOptions = {},
): string {
  const date = toDate(value);
  if (!isValidDate(date)) return "";

  const formatter = getRelativeTimeFormatter(locale, { numeric });
  const difference = date.getTime() - toDate(now).getTime();
  const magnitude = Math.abs(difference);

  for (const [unit, milliseconds] of RELATIVE_UNITS) {
    if (magnitude >= milliseconds) {
      return formatter.format(Math.round(difference / milliseconds), unit);
    }
  }

  return formatter.format(0, "second");
}

function formatNumber(value: number, { locale, ...options }: FormatNumberOptions = {}): string {
  if (!Number.isFinite(value)) return "";
  return getNumberFormatter(locale, options).format(value);
}

function formatCurrency(
  value: number,
  { locale, currency = "USD", ...options }: FormatCurrencyOptions = {},
): string {
  return formatNumber(value, { locale, style: "currency", currency, ...options });
}

function formatPercent(value: number, options: FormatNumberOptions = {}): string {
  return formatNumber(value, { style: "percent", maximumFractionDigits: 1, ...options });
}

function formatCompactNumber(value: number, options: FormatNumberOptions = {}): string {
  return formatNumber(value, { notation: "compact", maximumFractionDigits: 1, ...options });
}

function formatDuration(
  milliseconds: number,
  { locale, style = "short" }: FormatDurationOptions = {},
): string {
  if (!Number.isFinite(milliseconds)) return "";

  const sign = milliseconds < 0 ? "-" : "";
  const total = Math.round(Math.abs(milliseconds) / MS_PER_SECOND) * MS_PER_SECOND;

  const hours = Math.floor(total / MS_PER_HOUR);
  const minutes = Math.floor((total % MS_PER_HOUR) / MS_PER_MINUTE);
  const seconds = Math.floor((total % MS_PER_MINUTE) / MS_PER_SECOND);

  if (style === "clock") {
    return `${sign}${hours}:${String(minutes).padStart(2, "0")}`;
  }

  const unitDisplay = style === "long" ? "long" : "narrow";
  const parts: string[] = [];

  if (hours > 0) {
    parts.push(formatNumber(hours, { locale, style: "unit", unit: "hour", unitDisplay }));
  }

  if (minutes > 0) {
    parts.push(formatNumber(minutes, { locale, style: "unit", unit: "minute", unitDisplay }));
  }

  if (parts.length === 0 || (seconds > 0 && hours === 0)) {
    parts.push(formatNumber(seconds, { locale, style: "unit", unit: "second", unitDisplay }));
  }

  return `${sign}${parts.join(style === "long" ? ", " : " ")}`;
}

function parseDuration(input: string): number | null {
  const value = input.trim().toLowerCase();
  if (!value) return null;

  const clockMatch = /^(\d+):([0-5]?\d)$/u.exec(value);
  if (clockMatch) {
    return Number(clockMatch[1]) * MS_PER_HOUR + Number(clockMatch[2]) * MS_PER_MINUTE;
  }

  if (/^\d+(?:\.\d+)?$/u.test(value)) {
    return Number(value) * MS_PER_MINUTE;
  }

  const tokens = value.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)/gu);
  let total = 0;
  let matched = false;
  let consumed = 0;

  for (const [token, amount, unit] of tokens) {
    const unitMilliseconds = DURATION_UNIT_MILLISECONDS[unit];
    if (unitMilliseconds === undefined) return null;

    total += Number(amount) * unitMilliseconds;
    consumed += token.length;
    matched = true;
  }

  if (!matched) return null;
  if (consumed < value.replace(/\s+/gu, "").length) return null;

  return total;
}

export {
  formatCompactNumber,
  formatCurrency,
  formatDate,
  formatDateRange,
  formatDuration,
  formatNumber,
  formatPercent,
  formatRelativeTime,
  parseDuration,
  type DateInput,
  type DurationStyle,
  type FormatCurrencyOptions,
  type FormatDateOptions,
  type FormatDurationOptions,
  type FormatNumberOptions,
  type FormatRelativeTimeOptions,
};
```



## 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.

```ts
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`:

```ts
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:

```ts
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:

```ts
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

```ts
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:

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

## Durations

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

```ts
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:

```ts
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

| 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.

