Skip to content

OrdinalText

SM, under 950B gzipped

An ordinal via Intl.PluralRules — correct at 11th, and silent where suffixes do not apply.

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

Preview

Preview language
23rd

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

Why this exists

OrdinalText

English

The st/nd/rd/th rule breaks at 11, 12 and 13, and does not apply at all in languages that do not build ordinals from suffixes.

What most apps ship

11st · 12nd · 13rd · 21st

n % 10 === 1 ? `${n}st` : …

OrdinalText

11th · 12th · 13th · 21st

ordinal-text

Install

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

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

Source

"use client";

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

export interface OrdinalTextProps {
  value: number;
  /**
   * Suffixes keyed by CLDR ordinal category. Only needed for locales whose
   * ordinals are suffix-based, like English's st/nd/rd/th. Locales that form
   * ordinals differently fall back to the plain number, which is correct —
   * appending "th" to a Japanese numeral is worse than appending nothing.
   */
  suffixes?: Partial<Record<Intl.LDMLPluralRule, string>>;
}

const ENGLISH_SUFFIXES: Partial<Record<Intl.LDMLPluralRule, string>> = {
  one: "st",
  two: "nd",
  few: "rd",
  other: "th",
};

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

/**
 * An ordinal — 1st, 2nd, 3rd — using `Intl.PluralRules` in ordinal mode.
 *
 * The rule is not "1 → st, 2 → nd, 3 → rd, everything else → th": 11th, 12th
 * and 13th break it, and so does every locale that does not build ordinals by
 * suffix at all. `Intl.PluralRules` knows both.
 */
export function OrdinalText({ value, suffixes }: OrdinalTextProps) {
  const { locale } = useLocale();

  const text = useMemo(() => {
    const number = new Intl.NumberFormat(locale).format(value);
    const table = suffixes ?? (locale.startsWith("en") ? ENGLISH_SUFFIXES : undefined);
    if (!table) return number;

    const category = new Intl.PluralRules(locale, { type: "ordinal" }).select(value);
    return `${number}${table[category] ?? ""}`;
  }, [locale, value, suffixes]);

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

What CI checks

← All components