OrdinalText
EnglishThe 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` : …An ordinal via Intl.PluralRules — correct at 11th, and silent where suffixes do not apply.
This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.
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` : …npx shadcn@latest add https://kata-ui-rho.vercel.app/r/ordinal-text.jsonCopies the source into your project. Pulls in 2 primitives: use-locale, locale.
"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>;
}