Reactで、数字を漢数字に変換する入力欄を作る方法
数字を漢数字にする書き方は2つあります。「2026」を「二〇二六」のように1けたずつ置き換える書き方と、「二千二十六」のように十・百・千と万・億を使う書き方です。1けたずつのほうは Intl.NumberFormat でも作れます。万・億を使うほうは、検証した Node.js と Chromium の Intl.NumberFormat では作れなかったので、自分で書きます。
ここでは、数字を打つたびに漢数字が出る KanjiNumberInput というコンポーネントを作りました。2つの書き方を切り替えられ、京の位(20けた)までの整数を扱えます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。日本語入力の変換中の動きは、Chromium の開発者向けの機能(DevTools Protocol)で未確定の文字を入れて再現したもので、実際の IME では試していません。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げも試していません。

変換の関数
変換の処理は kanji.ts にまとめました。
// 数字の文字列を読み、漢数字にする関数
const DIGITS = '〇一二三四五六七八九';
const SMALL = ['', '十', '百', '千'];
const LARGE = ['', '万', '億', '兆', '京'];
// 京の位まで(20けた)
export const MAX = 10n ** 20n - 1n;
export type ParseResult =
| { kind: 'empty' }
| { kind: 'ok'; value: bigint }
| { kind: 'error'; message: string };
// 入力欄の文字列を、0以上の整数として読む
export function parseInteger(text: string): ParseResult {
// 全角の数字を半角にし、空白と桁区切りのカンマを取り除く
const s = text.normalize('NFKC').replace(/[\s,]/g, '');
if (s === '') return { kind: 'empty' };
if (!/^\d+$/.test(s)) return { kind: 'error', message: '0以上の整数を、数字で入力してください' };
const value = BigInt(s);
if (value > MAX) return { kind: 'error', message: '京の位(20けた)までの数にしてください' };
return { kind: 'ok', value };
}
// 1けたずつ漢数字にする(2026 → 二〇二六)
export function toKanjiDigits(value: bigint): string {
return value.toString().replace(/\d/g, (d) => DIGITS[Number(d)]);
}
// 0〜9999 を、十・百・千を使って書く。十・百・千の前の「一」は書かない
function fourDigits(n: number): string {
let out = '';
for (let i = 3; i >= 0; i--) {
const d = Math.floor(n / 10 ** i) % 10;
if (d === 0) continue;
out += (d === 1 && i > 0 ? '' : DIGITS[d]) + SMALL[i];
}
return out;
}
// 万・億・兆・京を使って書く(2026 → 二千二十六)
export function toKanjiNumber(value: bigint): string {
if (value === 0n) return '〇';
let out = '';
let i = 0;
// 下から4けたずつ区切り、0でない区切りに万・億・兆・京を付ける
for (let v = value; v > 0n; v /= 10000n, i++) {
const group = Number(v % 10000n);
if (group > 0) out = fourDigits(group) + LARGE[i] + out;
}
return out;
}
万・億を使う書き方は、4けたずつ区切って組み立てる
toKanjiNumber は、数を下から4けたずつに区切ります。それぞれの区切り(0〜9999)を fourDigits で「二千二十六」のように書き、いちばん下の区切りには何も付けず、その上の区切りから順に万・億・兆・京を付けてつなぎます。区切りが0のときは、その区切りごと飛ばします。
十・百・千の前の「一」は書かず、万・億・兆・京の前の「一」は書くことにしました。Unicode CLDR の日本語の数詞の規則(spellout-cardinal)も、1000 は「千」、10000 は前に数を付けて「一万」とする決め方で、0 は「〇」です。Node.js で試した結果は次のとおりです。
| 数字 | 万・億を使う | 1けたずつ |
|---|---|---|
| 0 | 〇 | 〇 |
| 10 | 十 | 一〇 |
| 1111 | 千百十一 | 一一一一 |
| 2026 | 二千二十六 | 二〇二六 |
| 10000 | 一万 | 一〇〇〇〇 |
| 11000 | 一万千 | 一一〇〇〇 |
| 11000000 | 千百万 | 一一〇〇〇〇〇〇 |
| 100010000 | 一億一万 | 一〇〇〇一〇〇〇〇 |
| 1234567890123 | 一兆二千三百四十五億六千七百八十九万百二十三 | 一二三四五六七八九〇一二三 |
1000万は「千万」、1億は「一億」になります。100010000 のように間の区切りが0でも、「一億一万」と、0の区切りを飛ばしてつながりました。
変換の誤りを探すため、確かめ用に漢数字を数に戻す関数を別に書き、往復させました。0〜200000 のすべての整数と、20けたまでの乱数10万個など、合わせて300,005個で、すべて元の数に戻りました。
1けたずつの書き方は、Intl.NumberFormat でも作れる
Intl.NumberFormat は、numberingSystem オプションか、ロケールの -u-nu- で、数字に使う文字を変えられます。hanidec を指定すると、0〜9 が「〇一二三四五六七八九」になります。
new Intl.NumberFormat('ja-JP-u-nu-hanidec').format(2026);
// → '二,〇二六'
new Intl.NumberFormat('ja-JP-u-nu-hanidec', { useGrouping: false }).format(2026);
// → '二〇二六'
何も指定しないと、3けたごとのカンマが入りました。useGrouping: false にすると、toKanjiDigits と同じ文字列になります。0〜100000 の整数と20けたまでの乱数10万個で比べたところ、Node.js では両者がすべて一致しました。Chromium でも、2026 は「二〇二六」になりました。
万・億を使う書き方は、検証環境の Intl.NumberFormat では作れませんでした。ECMA-402 の仕様では、表にある記数法は、0〜9 に当たる10文字で数字を置き換えます。hanidec はこの表にあります。表にない記数法の扱いは、実装に任されています。CLDR には、「二千二十六」の書き方を表す jpan という記数法もあります。ただし、CLDR の記数法の一覧では、jpan は10文字の置き換えではなく、前に挙げた数詞の規則で書く記数法です。検証環境の Node.js と Chromium では、ja-JP-u-nu-jpan を指定しても resolvedOptions().numberingSystem は latn になりました。Node.js で試すと、2026 は「2,026」のままで、Intl.supportedValuesOf('numberingSystem') にも jpan はありませんでした。
notation: 'compact' にすると、123456789 は「1.2億」になりました。億のような単位は付きますが、数字はアラビア数字のままで、下の位も丸められます。
20けたまでは BigInt で扱う
入力欄の文字列は、Number ではなく BigInt で数にしています。Number.MAX_SAFE_INTEGER の説明にあるとおり、Number で正しく表せる整数は 253 − 1(9007199254740991)までで、それより大きい整数には BigInt を使います。
検証環境の Node.js で、Number("9007199254740993") は 9007199254740992 になりました。BigInt("9007199254740993") は 9007199254740993 のままです。16けたの数を Number で読むと、入力とは違う数を漢数字にしてしまうことがあります。BigInt で読めば、この数も「九千七兆千九百九十二億五千四百七十四万九百九十三」と、末尾の三まで正しく出ます。
扱う上限は、京の位の最後(1020 − 1)にしました。99999999999999999999 は「九千九百九十九京…九千九百九十九」になり、21けたの数は「京の位(20けた)までの数にしてください」というエラーにしています。
全角の数字とカンマを読む
parseInteger は、最初に normalize('NFKC') をかけて、全角の数字を半角にします。そのあと空白とカンマを取り除き、数字だけが残ったときに BigInt に渡します。
| 入力 | 結果 |
|---|---|
| 123(全角) | 百二十三 |
| 1,234(前後に空白) | 千二百三十四 |
| 00042 | 四十二 |
| -5、1.5、1e3 | 0以上の整数を、数字で入力してください |
| 一二三 | 0以上の整数を、数字で入力してください |
| (空) | 何も出さない |
BigInt は、整数として読めない文字列を渡すと SyntaxError を投げます。正規表現で数字だけかを確かめてから渡しているので、その文字列は渡りません。負の数と小数は、この記事では扱わない入力として、エラーにしています。
入力欄のコンポーネントにまとめる
import { useId, useState } from 'react';
import { parseInteger, toKanjiDigits, toKanjiNumber } from './kanji';
type Style = 'number' | 'digits';
export function KanjiNumberInput({ initial = '' }: { initial?: string }) {
const id = useId();
const [text, setText] = useState(initial);
const [style, setStyle] = useState<Style>('number');
const [copied, setCopied] = useState(false);
const result = parseInteger(text);
const kanji =
result.kind !== 'ok' ? '' :
style === 'number' ? toKanjiNumber(result.value) : toKanjiDigits(result.value);
async function copy() {
await navigator.clipboard.writeText(kanji);
setCopied(true);
}
return (
<div className="kanji">
<label htmlFor={`${id}-num`}>数字</label>
<input
id={`${id}-num`}
inputMode="numeric"
autoComplete="off"
value={text}
aria-invalid={result.kind === 'error'}
aria-describedby={`${id}-msg`}
onChange={(e) => {
setText(e.target.value);
setCopied(false);
}}
/>
<p id={`${id}-msg`} className="error">
{result.kind === 'error' ? result.message : ''}
</p>
<fieldset>
<legend>書き方</legend>
<label>
<input type="radio" name={`${id}-style`} checked={style === 'number'} onChange={() => setStyle('number')} />
万・億を使う(二千二十六)
</label>
<label>
<input type="radio" name={`${id}-style`} checked={style === 'digits'} onChange={() => setStyle('digits')} />
1けたずつ(二〇二六)
</label>
</fieldset>
<output htmlFor={`${id}-num`}>{kanji}</output>
<button type="button" disabled={kanji === ''} onClick={copy}>
{copied ? 'コピーしました' : 'コピー'}
</button>
</div>
);
}
.kanji {
display: grid;
gap: 6px;
max-width: 360px;
}
.kanji input:not([type='radio']) {
font-size: 1.125rem;
padding: 6px 8px;
}
.kanji fieldset {
display: grid;
gap: 4px;
border: none;
padding: 0;
margin: 0;
}
.kanji .error {
min-height: 1.5em;
margin: 0;
color: #d85878;
}
.kanji output {
min-height: 1.6em;
font-size: 1.5rem;
color: #067a98;
}
.kanji button {
justify-self: start;
}
state に持つのは、入力欄の文字列、書き方、コピーしたかどうかの3つです。漢数字は state に持たず、レンダリングのたびに文字列から作ります。入力欄は type="text" のままにして、inputMode="numeric" を付けました。入力された文字列をそのまま parseInteger に渡し、全角の数字やカンマも読めるようにするためです。
Chromium で、空の欄に「2」「0」「2」「6」と1文字ずつ打つと、表示は「二」「二十」「二百二」「二千二十六」と変わりました。書き方を「1けたずつ」に切り替えると「二〇二六」になり、入力欄の文字列はそのまま残ります。「1.5」のように読めない文字列のときは、入力欄の aria-invalid が true になり、エラーの文が出て、コピーボタンは押せなくなりました。
コピーボタン
漢数字を別の場所に貼り付けられるように、navigator.clipboard.writeText() でコピーするボタンを付けました。このメソッドは、MDN の説明にあるとおり、保護されたコンテキスト(HTTPS など)でだけ使えます。保護されたコンテキストの説明によると、HTTPS でなくても、http://127.0.0.1 や http://localhost は信頼できる配信元として扱われます。検証では、Playwright でクリップボードの権限を与えたうえで、http://127.0.0.1:8080/ のページで試しました。100010000 を入れてボタンを押すと、クリップボードの中身は「一億一万」になり、ボタンの文字は「コピーしました」に変わりました。続けて数字を打つと、onChange の中で copied を false に戻しているので、ボタンの文字は「コピー」に戻りました。
長い漢数字は、CSS を足さなくても折り返される
万・億を使う書き方は、20けたの数だと40文字近くになります。画面の幅を 320px にして 99999999999999999999 を入れると、出力の欄(幅 272px)の中で折り返され、高さが 96px になりました。中身の幅は 272px で、横にはみ出していません。CSS で折り返しの指定はしていません。
変換中の未確定の文字でも、漢数字が出る
DevTools Protocol の Input.imeSetComposition を使い、日本語入力で変換を確定する前の状態として、全角の「20」「2026」を入力欄に入れました。確定前から onChange が呼ばれ、表示は「二十」「二千二十六」に変わりました。Input.insertText で確定したあとも、表示は「二千二十六」のままです。





