Reactで、ローマ字をひらがなに変換する入力欄を作る方法
ローマ字をひらがなに変える処理は、つづりとひらがなの対応表を作り、先頭からいちばん長く当てはまるつづりを探していけば書けます。表だけで決まらないのは「ん」と「っ」で、次に来る文字を見て決める決まりを足します。
入力欄に組み込むときは、onChange で欄の文字を変換して state に入れます。ただ、欄の文字全体を変換すると、文字の途中に打ったときにカーソルが末尾へ飛びました。ここでは、カーソルより前だけを変換し、変換のあとでカーソルを置き直す RomajiInput という部品を作りました。
検証環境は 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 です。tsc で型エラーがないことを確かめ、コンポーネントを StrictMode で包んで esbuild で開発用にビルドし、Playwright でキーを1つずつ送って操作しました。日本語入力(IME)をオンにしたままの入力、スマートフォンのソフトウェアキーボード、Firefox と Safari は試していません。カタカナへの変換も扱っていません。

つづりの表と、変換する関数
// ローマ字をひらがなに変換する関数
// 5つの母音と、子音ごとの行(母音の順は a i u e o)
const VOWELS = 'aiueo';
const ROWS: Record<string, string> = {
'': 'あいうえお',
k: 'かきくけこ',
s: 'さしすせそ',
t: 'たちつてと',
n: 'なにぬねの',
h: 'はひふへほ',
m: 'まみむめも',
r: 'らりるれろ',
g: 'がぎぐげご',
z: 'ざじずぜぞ',
d: 'だぢづでど',
b: 'ばびぶべぼ',
p: 'ぱぴぷぺぽ',
x: 'ぁぃぅぇぉ',
l: 'ぁぃぅぇぉ',
};
const TABLE: Record<string, string> = {};
for (const [c, kana] of Object.entries(ROWS)) {
[...VOWELS].forEach((v, i) => (TABLE[c + v] = kana[i]));
}
// 「きゃ」「しゃ」のように、小さい「ゃゅょ」が付く音
const YOUON: Record<string, string> = {
ky: 'き', sy: 'し', ty: 'ち', ny: 'に', hy: 'ひ', my: 'み', ry: 'り',
gy: 'ぎ', zy: 'じ', jy: 'じ', dy: 'ぢ', by: 'び', py: 'ぴ',
sh: 'し', ch: 'ち', cy: 'ち', j: 'じ',
};
for (const [c, kana] of Object.entries(YOUON)) {
TABLE[c + 'a'] = kana + 'ゃ';
TABLE[c + 'u'] = kana + 'ゅ';
TABLE[c + 'o'] = kana + 'ょ';
TABLE[c + 'e'] = kana + 'ぇ';
}
Object.assign(TABLE, {
// ヘボン式のつづりと、表にない音
shi: 'し', chi: 'ち', tsu: 'つ', fu: 'ふ', ji: 'じ',
ca: 'か', cu: 'く', co: 'こ',
ya: 'や', yu: 'ゆ', yo: 'よ', ye: 'いぇ',
wa: 'わ', wi: 'うぃ', we: 'うぇ', wo: 'を',
fa: 'ふぁ', fi: 'ふぃ', fe: 'ふぇ', fo: 'ふぉ',
va: 'ゔぁ', vi: 'ゔぃ', vu: 'ゔ', ve: 'ゔぇ', vo: 'ゔぉ',
thi: 'てぃ', dhi: 'でぃ', twu: 'とぅ', dwu: 'どぅ',
// 小さい文字
xya: 'ゃ', xyu: 'ゅ', xyo: 'ょ', lya: 'ゃ', lyu: 'ゅ', lyo: 'ょ',
xtu: 'っ', ltu: 'っ', xtsu: 'っ', ltsu: 'っ', xwa: 'ゎ', lwa: 'ゎ',
// 「ん」と記号
nn: 'ん', "n'": 'ん', xn: 'ん',
'-': 'ー', ',': '、', '.': '。',
});
const KEYS = Object.keys(TABLE);
const MAX_LEN = Math.max(...KEYS.map((k) => k.length));
const isConsonant = (c: string) => /^[bcdfghjklmpqrstvwxyz]$/.test(c);
// final が false のときは、末尾の打ちかけのローマ字(「k」「sh」「n」など)を残す
export function toHiragana(text: string, final = false): string {
let out = '';
let i = 0;
while (i < text.length) {
const rest = text.slice(i).toLowerCase();
// 表にある、いちばん長いつづりを探す
let hit = '';
for (let len = Math.min(MAX_LEN, rest.length); len > 0; len--) {
if (TABLE[rest.slice(0, len)]) {
hit = rest.slice(0, len);
break;
}
}
if (hit) {
out += TABLE[hit];
i += hit.length;
continue;
}
const [c, next] = rest;
// n のあとに母音・y・n・' 以外が来たら、その n は「ん」
if (c === 'n' && next !== undefined && !/[aiueoyn']/.test(next)) {
out += 'ん';
i += 1;
continue;
}
// 同じ子音が2つ続いたら「っ」(kka → っか)。tch も「っ」にする(matcha → まっちゃ)
if (isConsonant(c) && c !== 'n' && (next === c || rest.startsWith('tch'))) {
out += 'っ';
i += 1;
continue;
}
// 末尾の打ちかけは、そのまま残す(final のときの n だけは「ん」にする)
if (KEYS.some((k) => k.length > rest.length && k.startsWith(rest))) {
out += final && rest === 'n' ? 'ん' : text.slice(i);
break;
}
// 表にない文字(数字、漢字、変換できないつづり)は、そのまま
out += text[i];
i += 1;
}
return out;
}
表は、行ごとの「かきくけこ」と母音の「aiueo」を組み合わせて作り、そこに「shi」「chi」「tsu」のようなヘボン式のつづりや、「fa」「thi」などを足しています。訓令式の「si」「ti」「tu」「hu」も、行の組み合わせから表に入ります(「ti」は「ち」、「hu」は「ふ」)。
変換は、文字列の先頭から、表にあるいちばん長いつづりを探して置き換えていきます。「kyo」は「ky」+「o」ではなく3文字のまま表に当たるので「きょ」になり、「nya」も「n」+「ya」ではなく「にゃ」になります。大文字は小文字にしてから探すので、「NIHONGO」も「にほんご」になりました。
表で決まらない文字は、次の3つの決まりで扱います。
- 「n」のあとに母音・「y」・「n」・「'」以外の文字が来たら、「n」を「ん」にする(「kanji」→「かんじ」)
- 同じ子音が2つ続いたら、前の1つを「っ」にする(「kitte」→「きって」)。「tch」の「t」も「っ」にする(「matcha」→「まっちゃ」)
- 文字列の末尾が、表のどれかのつづりの書き始め(「k」「sh」「xt」など)なら、変換せずに残す
どれにも当たらない文字(数字や、「q」のように表にないつづり)は、そのまま残します。最後の決まりで残した打ちかけの文字は、次の文字を打ったときに、あらためて変換されます。final を true にしたときだけ、末尾に残った「n」を「ん」にします。
「ん」の打ち方で、結果が変わる例
toHiragana を Node.js で動かすと、次のようになりました。
| 入力 | 結果 |
|---|---|
| nihongo | にほんご |
| shinbun | しんぶn |
shinbun(final を true) |
しんぶん |
| konnnichiha | こんにちは |
| konnichiha | こんいちは |
| kon'nichiha | こんにちは |
| honya | ほにゃ |
| honnya / hon'ya | ほんや |
| chokore-to | ちょこれーと |
| kotoba,bunshou. | ことば、ぶんしょう。 |
| Tokyo | ときょ |
この関数では「nn」を「ん」にしているので、「konnichiha」の「nn」が「ん」になり、残った「i」が「い」になります。「こんにちは」にするには、「n」を3つ打つか、「n'」で区切ります。「honya」が「ほにゃ」になるのも、「nya」が表に当たるためです。
「Tokyo」が「ときょ」になるのは、つづりに長音の「u」がないためです。この関数は、書かれたつづりのとおりに変換します。
カーソルより前だけを変換する入力欄
import { useId, useLayoutEffect, useRef, useState } from 'react';
import { toHiragana } from './romaji';
// ローマ字で打つと、ひらがなに変わる入力欄
export function RomajiInput({ label }: { label: string }) {
const id = useId();
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// 変換したあとに、カーソルを置き直す位置
const caretRef = useRef<number | null>(null);
useLayoutEffect(() => {
const el = inputRef.current;
if (el && caretRef.current !== null) {
el.setSelectionRange(caretRef.current, caretRef.current);
caretRef.current = null;
}
});
return (
<div className="romaji">
<label htmlFor={id}>{label}</label>
<input
id={id}
ref={inputRef}
type="text"
autoComplete="off"
value={value}
onChange={(e) => {
const el = e.target;
const pos = el.selectionStart ?? el.value.length;
// カーソルより前だけを変換し、後ろはそのまま残す
const before = toHiragana(el.value.slice(0, pos));
setValue(before + el.value.slice(pos));
caretRef.current = before.length;
}}
onBlur={() => setValue(toHiragana(value, true))}
/>
</div>
);
}
.romaji {
display: grid;
gap: 6px;
max-width: 360px;
margin-bottom: 16px;
}
.romaji input {
font-size: 1.25rem;
padding: 6px 8px;
}
<input> には value と onChange を渡しているので、公式リファレンスでいう「制御された」入力欄になります。onChange では、selectionStart でカーソルの位置を取り、その前の部分だけを toHiragana に通します。後ろの部分は変換せずに、そのままつなぎます。
変換すると、カーソルより前の文字数が変わります(「ka」の2文字が「か」の1文字になる)。そこで、変換したあとの文字数を caretRef に入れておき、useLayoutEffect の中で setSelectionRange を呼んでカーソルを置き直します。useLayoutEffect の公式リファレンスにあるとおり、これはブラウザが画面を再描画する前に実行されます。
Playwright でキーを1つずつ送り、欄の文字とカーソルの位置を見ました。「shinbun」と打った直後の欄は「しんぶn」で、Tab キーで欄を離れると onBlur で final を true にして変換し、「しんぶん」になりました。「kitte」と打ってから Backspace を2回押すと、「きって」は「きっ」、「き」の順に1文字ずつ消えました。消えるのはひらがなの1文字で、打ったローマ字の1文字ではありません。
全体を変換すると、カーソルが末尾へ飛ぶ
比べるために、カーソルの位置を気にせず、欄の文字全体を変換する書き方も作りました。
import { useId, useState } from 'react';
import { toHiragana } from './romaji';
// 比べるための書き方:欄の文字全体を変換し、カーソルの位置は気にしない
export function NaiveRomajiInput({ label }: { label: string }) {
const id = useId();
const [value, setValue] = useState('');
return (
<div className="romaji">
<label htmlFor={id}>{label}</label>
<input
id={id}
type="text"
autoComplete="off"
value={value}
onChange={(e) => setValue(toHiragana(e.target.value))}
/>
</div>
);
}
どちらの欄にも「aiu」と打ち、左矢印キーを2回押してカーソルを「あ」の後ろに戻してから、「kaki」と打ちました。

全体を変換する書き方では、「k」を打った時点ではカーソルは「あk」の後ろ(2文字目の後ろ)にありました。「a」を打って「か」に変わると、欄は「あかいう」になり、カーソルは末尾(4文字目の後ろ)へ移りました。続けて打った「ki」は末尾に入り、欄は「あかいうき」になりました。
「k」だけのときは、変換しても欄の文字が変わらないので、カーソルは動きませんでした。この検証でカーソルが飛んだのは、onChange で入れた値が、打った直後の欄の文字と違ったときだけです。
カーソルより前だけを変換する書き方では、「a」を打ったあともカーソルは「か」の後ろに残り、欄は「あかきいう」になりました。同じように「あ」の後ろで「na」と打つと「あないう」になり、途中に打った「n」も、次の「a」とまとまって「な」になりました。
貼り付けのように、一度に文字が入る場合も確かめました。Playwright の fill で「toukyouto」を入れると、欄は「とうきょうと」になり、カーソルは末尾にありました。





