Skip to content

DateRangeText

SM, under 950B gzipped

A date range formatted as one range, collapsing shared parts in the locale's own calendar.

Category
Internationalisation
Budget
sm, single-purpose control
Runtime deps
None

Preview

Preview language
Jan 1 – 5, 2026

This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.

Why this exists

DateRangeText

日本語

Joining two formatted dates with a dash repeats everything they share, and puts the separator where a right-to-left locale does not want it.

What most apps ship

2026/01/01 - 2026/01/05

`${fmt(a)} - ${fmt(b)}`

DateRangeText

2026/01/01~2026/01/05

date-range-text

Install

npx shadcn@latest add https://kata-ui-rho.vercel.app/r/date-range-text.json

Copies the source into your project. Pulls in 2 primitives: locale, use-locale.

Source

"use client";

import { useMemo } from "react";
import { getCalendar } from "../lib/locale";
import { useLocale } from "../lib/use-locale";

export interface DateRangeTextProps {
  start: Date | number;
  end: Date | number;
  options?: Intl.DateTimeFormatOptions;
}

/*
 * Rendered inside a `suppressHydrationWarning` span.
 *
 * `Intl` output is not byte-identical across ICU versions, and Node's ICU is
 * not the browser's. This exact call produces "Jan 1 <U+2009>–<U+2009> 5, 2026"
 * on Node and "Jan 1 <U+0020>–<U+0020> 5, 2026" in Chrome — visually
 * identical, different bytes — so every server-rendered use would throw a
 * hydration error in a consumer's app through no fault of theirs.
 *
 * This is the case React documents the escape hatch for. The suppression is
 * scoped to this one text node, so a genuine structural mismatch anywhere else
 * still reports normally.
 */

/**
 * A date range formatted as one range, not two dates with a dash between them.
 *
 * `Intl.DateTimeFormat#formatRange` collapses the parts the two dates share:
 * English gives "1–5 January 2026" rather than "1 January 2026 – 5 January
 * 2026", and every locale collapses differently, using its own range separator
 * — which is not always a hyphen, and in RTL locales does not sit where a
 * hardcoded `${a} - ${b}` would put it.
 *
 * The range also goes through the locale's own calendar, so a Thai reader sees
 * Buddhist-era years and a Saudi reader sees Hijri ones.
 */
export function DateRangeText({ start, end, options }: DateRangeTextProps) {
  const { locale } = useLocale();

  const text = useMemo(() => {
    const formatter = new Intl.DateTimeFormat(locale, {
      dateStyle: "medium",
      calendar: getCalendar(locale),
      ...options,
    });

    return formatter.formatRange(new Date(start), new Date(end));
  }, [locale, start, end, options]);

  return <span suppressHydrationWarning>{text}</span>;
}

What CI checks

← All components