Skip to contentVibraUI
Foundation

Format

Number, currency, byte, duration, and date formatting helpers built on the Intl API.

Every locale parameter defaults to "en-US". formatDelta uses U+2212 (−) for negatives, never a hyphen. formatDate and formatDateTime read an instant in the runtime's zone unless given a timeZone, so a value stored in UTC wants timeZone: "UTC" — otherwise it lands on the previous day west of Greenwich. Their Intl.DateTimeFormat instances are shared per locale, style and zone, so a long strip of days constructs one formatter rather than one per day.

Install

npx shadcn@latest add @vibra/format

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
formatNumber(value: number, opts?: { locale?: string; maximumFractionDigits?: number; minimumFractionDigits?: number }) => stringmaximumFractionDigits: 2Thousands-separated number, e.g. 1234567.891 → "1,234,567.89".
formatCompact(value: number, opts?: { locale?: string; maximumFractionDigits?: number }) => stringmaximumFractionDigits: 1Abbreviated number, e.g. 1234567 → "1.2M", 950 → "950".
formatCurrency(value: number, currency?: string, opts?: { locale?: string; maximumFractionDigits?: number; compact?: boolean }) => stringcurrency: "USD"Currency amount, e.g. 1234.5 → "$1,234.50"; compact abbreviates large amounts.
formatPercent(value: number, opts?: { locale?: string; maximumFractionDigits?: number }) => stringmaximumFractionDigits: 1A 0..1 ratio as a percentage, e.g. 0.1234 → "12.3%".
formatDelta(value: number, opts?: { style?: "percent" | "number" | "compact"; locale?: string; maximumFractionDigits?: number }) => stringstyle: "percent"Signed change with a leading "+"/U+2212, e.g. 0.12 → "+12.0%", -3 (number style) → "−3".
formatBytes(bytes: number, decimals?: number) => stringdecimals: 2Byte count at the largest readable binary unit, e.g. 1536 → "1.5 KB".
formatDuration(ms: number) => string—Millisecond duration as a short string, e.g. 5400000 → "1h 30m".
formatDate(date: Date | string | number, style?: "short" | "medium" | "long", options?: string | { locale?: string; timeZone?: string }) => stringstyle: "medium"A date, e.g. medium → "Sep 4, 2026". The third argument still takes a bare locale string; pass timeZone: "UTC" for a stored instant, or the runtime zone decides what day it is.
formatDateTime(date: Date | string | number, options?: { dateStyle?: "short" | "medium" | "long"; timeStyle?: "short" | "medium"; locale?: string; timeZone?: string }) => stringdateStyle: "medium", timeStyle: "short"A date and the time on it, e.g. "Sep 4, 2026, 3:30 PM". Same zone rule as formatDate.
formatRelative(date: Date | string | number, now?: Date, options?: { locale?: string; timeZone?: string }) => stringnow: new Date(), timeZone: "UTC"A date relative to now — "just now" under 45s, then "3m ago"/"in 2h", falling back to formatDate short at 7+ days, read in UTC unless options.timeZone says otherwise, so the server and the browser print the same day.
numberFormatter(format: NumberFormat) => (value: number) => string—The function a NumberFormat stands for: a function passes through, and plain Intl.NumberFormat options (with a locale among them) — what a server component can hand a client one — become a formatter.
clamp(n: number, min: number, max: number) => number—Clamps n to the inclusive [min, max] range.
percentOf(part: number, total: number) => number—What percentage part is of total; 0 when total is zero or negative.
getInitials(name: string, max?: number) => stringmax: 2Up to max uppercase initials, e.g. "Ada Lovelace" → "AL".

Dependencies

Registry

Source

lib/format.ts
// Formatting helpers built on Intl — no runtime dependencies. Every locale
// parameter defaults to "en-US" so output is deterministic in tests and docs.

const DEFAULT_LOCALE = "en-US"

const MINUTE = 60_000
const HOUR = 3_600_000
const DAY = 86_400_000

/** Formats a number with locale thousands separators, e.g. 1234567.891 → "1,234,567.89". */
export function formatNumber(
  value: number,
  opts: { locale?: string; maximumFractionDigits?: number; minimumFractionDigits?: number } = {}
): string {
  const { locale = DEFAULT_LOCALE, maximumFractionDigits = 2, minimumFractionDigits } = opts
  return new Intl.NumberFormat(locale, { maximumFractionDigits, minimumFractionDigits }).format(value)
}

/** Abbreviates a number to its shortest human form, e.g. 1234567 → "1.2M", 950 → "950". */
export function formatCompact(value: number, opts: { locale?: string; maximumFractionDigits?: number } = {}): string {
  const { locale = DEFAULT_LOCALE, maximumFractionDigits = 1 } = opts
  return new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits }).format(value)
}

/** Formats a currency amount, e.g. 1234.5 → "$1,234.50"; pass `compact` to abbreviate large amounts. */
export function formatCurrency(
  value: number,
  currency = "USD",
  opts: { locale?: string; maximumFractionDigits?: number; compact?: boolean } = {}
): string {
  const { locale = DEFAULT_LOCALE, maximumFractionDigits, compact = false } = opts
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
    notation: compact ? "compact" : "standard",
    maximumFractionDigits: maximumFractionDigits ?? (compact ? 1 : undefined),
  }).format(value)
}

/** Formats a 0..1 ratio as a percentage, e.g. 0.1234 → "12.3%". */
export function formatPercent(value: number, opts: { locale?: string; maximumFractionDigits?: number } = {}): string {
  const { locale = DEFAULT_LOCALE, maximumFractionDigits = 1 } = opts
  return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits }).format(value)
}

/** Formats a signed change: a leading "+" for positive values, U+2212 (not a hyphen) for negative, and a plain "0%"/"0" for exactly zero. */
export function formatDelta(
  value: number,
  opts: { style?: "percent" | "number" | "compact"; locale?: string; maximumFractionDigits?: number } = {}
): string {
  const { style = "percent", locale = DEFAULT_LOCALE, maximumFractionDigits } = opts
  if (value === 0) return style === "percent" ? "0%" : "0"

  const sign = value > 0 ? "+" : "−"
  const magnitude = Math.abs(value)

  if (style === "compact") return `${sign}${formatCompact(magnitude, { locale, maximumFractionDigits: maximumFractionDigits ?? 1 })}`
  if (style === "number") {
    const digits = maximumFractionDigits ?? 0
    return `${sign}${new Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(magnitude)}`
  }
  const digits = maximumFractionDigits ?? 1
  return `${sign}${new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: digits, maximumFractionDigits: digits }).format(magnitude)}`
}

const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const

/** Formats a byte count at the largest binary unit that keeps it readable, e.g. 1536 → "1.5 KB". */
export function formatBytes(bytes: number, decimals = 2): string {
  if (bytes === 0) return "0 B"
  let value = bytes
  let unitIndex = 0
  while (Math.abs(value) >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
    value /= 1024
    unitIndex += 1
  }
  const rounded = parseFloat(value.toFixed(Math.max(0, decimals)))
  return `${rounded} ${BYTE_UNITS[unitIndex]}`
}

// Rounds once, to whole seconds, then derives every coarser unit by exact integer floor/modulo — never
// re-rounding a remainder — so a value that rounds up to a unit's modulus (e.g. 59.5s → 60s) carries over
// ("1m") instead of overshooting ("60s").
/** Formats a millisecond duration as a short human string, e.g. 5400000 → "1h 30m". */
export function formatDuration(ms: number): string {
  if (ms < 1000) return `${Math.round(ms)}ms`

  const totalSeconds = Math.round(ms / 1000)
  if (totalSeconds < 60) return `${totalSeconds}s`

  const totalMinutes = Math.floor(totalSeconds / 60)
  const seconds = totalSeconds % 60
  if (totalMinutes < 60) return seconds > 0 ? `${totalMinutes}m ${seconds}s` : `${totalMinutes}m`

  const totalHours = Math.floor(totalMinutes / 60)
  const minutes = totalMinutes % 60
  if (totalHours < 24) return minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`

  const days = Math.floor(totalHours / 24)
  const hours = totalHours % 24
  return hours > 0 ? `${days}d ${hours}h` : `${days}d`
}

/** How a date is rendered: which locale writes it, and which zone decides what day it is. */
export type DateFormatOptions = {
  locale?: string
  /**
   * IANA zone the instant is read in. Left off, the runtime's own zone decides
   * — right for a date the reader picked off a calendar, wrong for an instant
   * stored in UTC, which lands on the previous day west of Greenwich. Pass
   * `"UTC"` whenever the value came from a database rather than a date picker.
   */
  timeZone?: string
}

// Constructing an Intl.DateTimeFormat is the expensive part and each one is
// immutable, so they are shared by their options: a strip of 720 day tooltips
// built 720 formatters before this.
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>()

function dateTimeFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
  const key = `${locale}|${options.dateStyle ?? ""}|${options.timeStyle ?? ""}|${options.timeZone ?? ""}`
  const cached = dateTimeFormatters.get(key)
  if (cached) return cached
  const formatter = new Intl.DateTimeFormat(locale, options)
  dateTimeFormatters.set(key, formatter)
  return formatter
}

/** Formats a date, e.g. medium: "Sep 4, 2026". Accepts a `Date`, ISO string, or epoch number, and a locale string or `{ locale, timeZone }`. */
export function formatDate(
  date: Date | string | number,
  style: "short" | "medium" | "long" = "medium",
  options: string | DateFormatOptions = {}
): string {
  const { locale = DEFAULT_LOCALE, timeZone } =
    typeof options === "string" ? { locale: options, timeZone: undefined } : options
  const value = date instanceof Date ? date : new Date(date)
  return dateTimeFormat(locale, { dateStyle: style, timeZone }).format(value)
}

/** Formats a date and the time on it, e.g. "Sep 4, 2026, 3:30 PM". Same zone rule as `formatDate`: pass `timeZone: "UTC"` for a stored instant. */
export function formatDateTime(
  date: Date | string | number,
  options: DateFormatOptions & {
    dateStyle?: "short" | "medium" | "long"
    timeStyle?: "short" | "medium"
  } = {}
): string {
  const { locale = DEFAULT_LOCALE, timeZone, dateStyle = "medium", timeStyle = "short" } = options
  const value = date instanceof Date ? date : new Date(date)
  return dateTimeFormat(locale, { dateStyle, timeStyle, timeZone }).format(value)
}

/**
 * Formats a date relative to `now` (default: current time): "just now" under
 * 45s, then "3m ago"/"in 2h", falling back to `formatDate(date, "short")` at
 * 7+ days; returns "" for an invalid date. The fallback is a calendar date of
 * a stored instant, so it is read in UTC unless `options.timeZone` says
 * otherwise: in the reader's own zone an instant after midnight UTC printed the
 * day before west of Greenwich, and the server's page and the browser's
 * disagreed.
 */
export function formatRelative(date: Date | string | number, now: Date = new Date(), options: DateFormatOptions = {}): string {
  const target = date instanceof Date ? date : new Date(date)
  if (Number.isNaN(target.getTime()) || Number.isNaN(now.getTime())) return ""

  const diff = target.getTime() - now.getTime()
  const abs = Math.abs(diff)

  if (abs < 45_000) return "just now"
  if (abs >= 7 * DAY) return formatDate(target, "short", { timeZone: "UTC", ...options })

  const magnitude = abs < HOUR ? `${Math.round(abs / MINUTE)}m` : abs < DAY ? `${Math.round(abs / HOUR)}h` : `${Math.round(abs / DAY)}d`
  return diff < 0 ? `${magnitude} ago` : `in ${magnitude}`
}

/**
 * How a component formats a number it is handed: a function, or — what a
 * server component can pass across to a client one, since a function cannot
 * cross — the options of an `Intl.NumberFormat`, with the locale among them.
 */
export type NumberFormat = ((value: number) => string) | (Intl.NumberFormatOptions & { locale?: string })

/** The function a NumberFormat stands for; plain options become an `Intl.NumberFormat`. */
export function numberFormatter(format: NumberFormat): (value: number) => string {
  if (typeof format === "function") return format
  const { locale = DEFAULT_LOCALE, ...options } = format
  const formatter = new Intl.NumberFormat(locale, options)
  return (value) => formatter.format(value)
}

/** Clamps `n` to the inclusive [min, max] range. */
export function clamp(n: number, min: number, max: number): number {
  return Math.min(Math.max(n, min), max)
}

/** Returns what percentage `part` is of `total`, or 0 when `total` is zero or negative. */
export function percentOf(part: number, total: number): number {
  if (total <= 0) return 0
  return (part / total) * 100
}

/** Extracts up to `max` (default 2) uppercase initials from a name, e.g. "Ada Lovelace" → "AL". */
export function getInitials(name: string, max = 2): string {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  return parts
    .slice(0, max)
    .map((part) => part[0]?.toUpperCase() ?? "")
    .join("")
}