Reactで、絵文字を選んで入力欄に入れるピッカーを作る方法
ピッカーで選んだ絵文字をテキストエリアに入れるとき、state の文字列を組み立て直して入れると、検証環境の Chromium では、入れたあとに Ctrl+Z を押しても元に戻せなくなりました。document.execCommand('insertText') でブラウザの編集として入れれば、元に戻す履歴が残り、React の onChange もふつうの入力と同じように呼ばれます。
ピッカーの検索欄にフォーカスが移っても、テキストエリアのキャレットの位置は残っています。選んだときにテキストエリアへフォーカスを戻せば、絵文字はキャレットがあった場所に入ります。ここでは、検索、矢印キーでの移動、最近使った絵文字の保存までを含めたピッカーを作ります。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、ヘッドレスの Chromium を Playwright で操作して確かめました。StrictMode を付けた開発ビルドでも結果は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。Firefox と Safari、スマートフォンのブラウザ、日本語の IME での入力、スクリーンリーダーでの読み上げは試していません。

絵文字のデータは、検索に使う言葉と一緒に持つ
絵文字の一覧は、文字そのものと、検索に使う名前とキーワードを1つのオブジェクトにまとめた配列にしました。検証では40個の絵文字を並べています。先頭の部分だけを抜き出すと、次のようになります。
export type Emoji = { char: string; name: string; keywords: string[] };
// 検索に使う名前とキーワードは、ひらがなと漢字で持つ
export const EMOJIS: Emoji[] = [
{ char: '😀', name: 'にっこり', keywords: ['笑顔', 'えがお'] },
{ char: '😂', name: 'うれし泣き', keywords: ['笑う', 'わらう', '涙'] },
{ char: '😍', name: 'ハートの目', keywords: ['好き', 'すき'] },
name は、ボタンの aria-label にも使います。絵文字だけが書かれたボタンにも名前が付くので、検証で使った Playwright のテストでは、getByRole で「黒ねこ」という名前からボタンを探せました(スクリーンリーダーでの読み上げは試していません)。
入れる処理は、ブラウザの編集として行う
テキストエリアに絵文字を入れる関数です。テキストエリアにフォーカスを戻してから、選ばれている範囲(何も選んでいなければキャレットの位置)を絵文字で置き換えます。
// テキストエリアの選択範囲を text で置き換え、キャレットを text の直後に置く
export function insertAtCaret(el: HTMLTextAreaElement, text: string) {
el.focus();
// ブラウザの編集として入れると、Ctrl+Z(元に戻す)の履歴に残り、input イベントも起きる
if (document.execCommand('insertText', false, text)) return;
// execCommand が使えなかったとき
el.setRangeText(text, el.selectionStart, el.selectionEnd, 'end');
el.dispatchEvent(new Event('input', { bubbles: true }));
}
React らしく書くなら、value.slice(0, start) + text + value.slice(end) を state に入れ、useLayoutEffect でキャレットを戻す方法もあります。以前の記事「Reactで、入力欄で「@」を打つと名前の候補を出す方法」でも、この書き方で名前を入れました。検証環境の Chromium で、2つの書き方を比べました。どちらも、「ねこ です」の「ねこ」の後ろに黒ねこ(🐈⬛)を入れ、続けて Ctrl+Z を3回押しました。
--- state を書き換えて入れる ---
入れた直後: "ねこ🐈⬛ です" キャレット 6 フォーカス テキストエリア
Ctrl+Z 1回目: "ねこ🐈⬛ です"
Ctrl+Z 2回目: "ねこ🐈⬛ です"
Ctrl+Z 3回目: "ねこ🐈⬛ です"
--- insertAtCaret で入れる ---
入れた直後: "ねこ🐈⬛ です" キャレット 6 フォーカス テキストエリア
Ctrl+Z 1回目: "ねこ です"
Ctrl+Z 2回目: "ねこ で"
Ctrl+Z 3回目: "ねこ "
insertAtCaret 側の state: "ねこ "
state を書き換えた場合は、Ctrl+Z を何回押しても値が変わりませんでした。絵文字を入れる前に打った「です」も戻せません。insertAtCaret で入れた場合は、1回目で絵文字が消え、そのあとは打った「です」が後ろから1文字ずつ消えました。Ctrl+Z で戻したときも input イベントが起きて onChange が呼ばれるので、React の state も DOM の値と同じ「ねこ 」になっています。
MDN の execCommand() の説明では、このメソッドは非推奨(deprecated)とされています。そのうえで、DOM を直接書き換える方法と違って編集の履歴(元に戻す履歴)が残るので、ほかに代わりがない使い方では使ってよい、ただし複数のブラウザで動きを確かめること、と書かれています。
同じ MDN の説明によると、execCommand() で input イベントが起きるかどうかは、ブラウザや設定によって違います。また、execCommand() が true を返すのは、ユーザーの操作の中で呼んだときだけです。await のあとなど、クリックやキー操作の処理から外れたところで呼んで false が返った場合は、下の setRangeText() の側で入るので、Ctrl+Z では戻せないと考えられます。この場合は試しておらず、筆者が確かめたのは、クリックと Enter の処理の中から呼んだ Chromium の場合だけです。
execCommand() が false を返したときは、setRangeText() で選択範囲を置き換えます。4つ目の引数の 'end' で、キャレットを入れた文字の直後に置きます。MDN の setRangeText() の説明で、起きるイベントとして挙がっているのは select と selectionchange だけです。そこで、自分で input イベントを送って onChange を呼ばせます。
execCommand が必ず false を返すようにして試したところ、絵文字は「ねこ」の後ろに入り、キャレットも絵文字の直後(位置 6)に来ました。そのあとピッカーを開いて閉じ、コンポーネントを描き直させても、値は「ねこ🐈⬛ です」のままでした。input イベントで state が更新されているためです。ただし、この場合は Ctrl+Z を押しても値は変わりませんでした。
1つの絵文字でも、length は1とは限らない
上の結果で、「ねこ」(2文字)の後ろに絵文字を1つ入れたのに、キャレットの位置は 3 ではなく 6 でした。selectionStart などの位置は、文字列の length と同じ数え方をするからです。length は UTF-16 のコード単位の数で(MDN の String の length の説明)、絵文字のように1文字に見えても、2以上になることがあります。このことは、以前の記事「Reactで、入力した文字数を数えて上限を知らせる方法」でもくわしく扱いました。
👍 length 2 U+1F44D
👍🏽 length 4 U+1F44D U+1F3FD
🐈⬛ length 4 U+1F408 U+200D U+2B1B
❤️ length 2 U+2764 U+FE0F
🇯🇵 length 4 U+1F1EF U+1F1F5
黒ねこは、ねこ(U+1F408)と黒い四角(U+2B1B)を、ゼロ幅接合子(ZWJ、U+200D)でつないだもので、length は 4 です。肌の色を付けた絵文字や国旗も 4 になりました。state を書き換えて入れる書き方では、キャレットを戻す位置を start + text.length と計算します。start + 1 にすると、絵文字の途中にキャレットを置くことになります。insertAtCaret では、キャレットの位置をブラウザが決めるので、この計算はいりません。
入れた絵文字の直後で Backspace を1回押すと、Chromium ではどの絵文字も丸ごと消え、値は空になりました。
いいね: 入れた値 👍(length 2)→ Backspace 1回で (空)
いいね(中間の肌の色): 入れた値 👍🏽(length 4)→ Backspace 1回で (空)
黒ねこ: 入れた値 🐈⬛(length 4)→ Backspace 1回で (空)
ハート: 入れた値 ❤️(length 2)→ Backspace 1回で (空)
日本の旗: 入れた値 🇯🇵(length 4)→ Backspace 1回で (空)
ピッカーのコンポーネント
ピッカーを開くと、検索欄にフォーカスが移ります。検索欄に打った言葉が、名前かキーワードのどれかに含まれる絵文字だけを並べます。検索欄で Enter を押すと先頭の絵文字を選び、↓を押すと一覧に移ります。
一覧の中は矢印キーで動けるようにしました。WAI-ARIA Authoring Practices の、roving tabindex の説明にあるやり方で、Tab でフォーカスが入るのは今いるボタン(tabIndex が 0)だけにし、ほかのボタンは tabIndex を -1 にします。40個のボタンが全部 Tab の順番に入ると、一覧を抜けるのに Tab を何十回も押すことになるためです。
import { useRef, useState, type KeyboardEvent } from 'react';
import { EMOJIS, type Emoji } from './emojis';
const COLS = 8; // CSS の grid-template-columns と同じ列数にする
const NAMES = new Map(EMOJIS.map((e) => [e.char, e.name]));
export function filterEmojis(query: string): Emoji[] {
const q = query.trim().toLowerCase();
if (!q) return EMOJIS;
return EMOJIS.filter((e) => [e.name, ...e.keywords].some((w) => w.toLowerCase().includes(q)));
}
type Props = {
recent: string[];
onPick: (char: string) => void;
onClose: () => void;
};
export function EmojiPicker({ recent, onPick, onClose }: Props) {
const [query, setQuery] = useState('');
const [active, setActive] = useState(0); // 一覧の中で Tab で入れる位置
const gridRef = useRef<HTMLDivElement>(null);
const items = filterEmojis(query);
const focusItem = (i: number) => {
setActive(i);
gridRef.current?.querySelectorAll<HTMLButtonElement>('button')[i]?.focus();
};
const onSearchKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown' && items.length > 0) {
e.preventDefault();
focusItem(0);
} else if (e.key === 'Enter' && items.length > 0) {
e.preventDefault();
onPick(items[0].char);
}
};
const onGridKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const steps: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLS, ArrowUp: -COLS };
const step = steps[e.key];
if (step === undefined) return;
e.preventDefault();
const next = active + step;
if (next >= 0 && next < items.length) focusItem(next);
};
return (
<div
className="picker"
role="dialog"
aria-label="絵文字を選ぶ"
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}}
>
<input
type="search"
aria-label="絵文字を検索"
placeholder="検索(例:ねこ)"
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={onSearchKeyDown}
/>
{query === '' && recent.length > 0 && (
<div className="recent" role="group" aria-label="最近使った絵文字">
{recent.map((c) => (
<button key={c} type="button" aria-label={NAMES.get(c) ?? c} onClick={() => onPick(c)}>
{c}
</button>
))}
</div>
)}
{items.length === 0 ? (
<p className="empty">見つかりませんでした</p>
) : (
<div className="grid" role="group" aria-label="絵文字の一覧" ref={gridRef} onKeyDown={onGridKeyDown}>
{items.map((e, i) => (
<button
key={e.char}
type="button"
tabIndex={i === active ? 0 : -1}
aria-label={e.name}
title={e.name}
onFocus={() => setActive(i)}
onClick={() => onPick(e.char)}
>
{e.char}
</button>
))}
</div>
)}
</div>
);
}
↑と↓では、列の数(COLS)だけ前後に動きます。一覧は CSS で grid-template-columns: repeat(8, 1fr); にしているので、8つ先のボタンが1段下のボタンです。CSS の列数を変えたら、COLS も合わせて変えます。検索欄の文字が変わるたびに、active を 0 に戻しています。絞り込みで一覧が短くなったときに、active が一覧の数以上のままだと、Tab でフォーカスが入るボタンがなくなるためです。
最近使った絵文字を localStorage に残す
最近使った絵文字は、新しいものから8個まで localStorage に保存します。保存した値が壊れているとき(JSON として読めない、配列でないなど)は、空の一覧として扱います。
const KEY = 'emoji-recent';
const MAX = 8;
// 保存した値が壊れていたり、localStorage が使えなかったりしたら空にする
export function loadRecent(): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]');
return Array.isArray(v) ? v.filter((c): c is string => typeof c === 'string').slice(0, MAX) : [];
} catch {
return [];
}
}
// 使った絵文字を先頭に移し、MAX 個までにして保存する
export function pushRecent(list: string[], char: string): string[] {
const next = [char, ...list.filter((c) => c !== char)].slice(0, MAX);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
// 保存できなくても、開いている間の一覧は使える
}
return next;
}
try で囲んでいるのは、localStorage が例外を投げることがあるためです。MDN によると、localStorage の読み出しは、オリジンが file: や data: の場合などに SecurityError を投げます。setItem() は、保存できる容量を使い切ったときなどに QuotaExceededError を投げます。検証では、例外が起きる場合は試していません。
テキストエリアとピッカーをつなぐ
テキストエリア、ピッカーを開くボタン、ピッカーをまとめたコンポーネントです。絵文字が選ばれたら、ピッカーを閉じて insertAtCaret で入れ、最近使った絵文字の一覧を更新します。
import { useEffect, useRef, useState } from 'react';
import { EmojiPicker } from './EmojiPicker';
import { insertAtCaret } from './insertAtCaret';
import { loadRecent, pushRecent } from './recent';
export function EmojiTextarea() {
const [value, setValue] = useState('');
const [open, setOpen] = useState(false);
const [recent, setRecent] = useState(loadRecent);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
// ピッカーの外を押したら閉じる
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!popupRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
const pick = (char: string) => {
setOpen(false);
insertAtCaret(textareaRef.current!, char);
setRecent((list) => pushRecent(list, char));
};
const close = () => {
setOpen(false);
textareaRef.current?.focus();
};
return (
<div className="emoji-field">
<textarea
ref={textareaRef}
aria-label="メッセージ"
rows={3}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<div className="popup" ref={popupRef}>
<button
type="button"
className="toggle"
aria-label="絵文字を入れる"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
😀
</button>
{open && <EmojiPicker recent={recent} onPick={pick} onClose={close} />}
</div>
</div>
);
}
外側を押したかどうかは、ボタンとピッカーを囲んだ popupRef の中かどうかで決めます。ボタンもこの中に入れているので、開いているときにボタンを押すと、pointerdown の処理では閉じず、onClick で open が反転して閉じます。
動かして確かめた結果
ページを Chromium で開き、Playwright でキーボードとマウスを操作しました。各行は、操作のあとのテキストエリアの値、選択範囲(selectionStart–selectionEnd)、フォーカスのある場所、ピッカーが開いているかどうか、ボタンの aria-expanded です。まず、「ねこ です」のキャレットを「ねこ」の後ろに置いてからピッカーを開き、キーボードだけで黒ねこを入れました。一覧の項目の後ろの * は、tabIndex が 0 のボタンです。
キャレットを「ねこ」の後ろへ: 値 "ねこ です" 選択 2-2 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
ボタンを押した: 値 "ねこ です" 選択 2-2 フォーカス 絵文字を検索 ピッカー 開 aria-expanded=true
一覧の数: 40
「ねこ」で検索: 🐱ねこの顔* 🐈ねこ 🐈⬛黒ねこ
検索欄に打った: 値 "ねこ です" 選択 2-2 フォーカス 絵文字を検索 ピッカー 開 aria-expanded=true
↓: 値 "ねこ です" 選択 2-2 フォーカス 絵文字の一覧のねこの顔 ピッカー 開 aria-expanded=true
→ を2回: 値 "ねこ です" 選択 2-2 フォーカス 絵文字の一覧の黒ねこ ピッカー 開 aria-expanded=true
Tab で入る位置(*): 🐱ねこの顔 🐈ねこ 🐈⬛黒ねこ*
Enter: 値 "ねこ🐈⬛ です" 選択 6-6 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
続けて「がいる」と打った: 値 "ねこ🐈⬛がいる です" 選択 9-9 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
フォーカスが検索欄や一覧のボタンにある間も、テキストエリアの選択範囲は 2-2 のまま残っていました。Enter で選ぶと、絵文字は「ねこ」の後ろに入り、フォーカスはテキストエリアに戻りました。続けて打った「がいる」も、絵文字の後ろに入っています。
文字を選んでからピッカーで絵文字を選ぶと、選んだ文字が絵文字に置き換わります。「です」を選んでから、マウスで「いいね」を押しました。
「です」を選んだ: 値 "ねこ🐈⬛がいる です" 選択 10-12 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
「いいね」をクリック: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
次に、検索欄に何も打たずに↓で一覧に入り、矢印キーで動きました。いちばん上の段で↑を押しても、フォーカスは動きません。一覧から Shift+Tab を押すと、最近使った絵文字の行に移りました。
↓(検索欄から一覧へ): 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のにっこり ピッカー 開 aria-expanded=true
↓: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のいいね ピッカー 開 aria-expanded=true
←: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のこらえる ピッカー 開 aria-expanded=true
↑ を2回(1段目より上はない): 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のこらえる ピッカー 開 aria-expanded=true
Shift+Tab: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 最近使った絵文字の黒ねこ ピッカー 開 aria-expanded=true
Shift+Tab: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 最近使った絵文字のいいね ピッカー 開 aria-expanded=true
一覧の「にっこり」で↓を押すと、8つ先の「いいね」に移りました。そこから←を押すと、1つ前の「こらえる」(1段目の右端)に移ります。
閉じ方も確かめました。Esc ではピッカーが閉じてフォーカスがテキストエリアに戻り、選択範囲もそのままでした。ピッカーの外をクリックしたときと、開いているときにボタンをもう一度押したときも、ピッカーは閉じました。
Esc: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
ピッカーの外をクリック: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス BODY ピッカー 閉 aria-expanded=false
ボタンをもう一度押した: 値 "ねこ🐈⬛がいる 👍" 選択 12-12 フォーカス 絵文字を入れる ピッカー 閉 aria-expanded=false
検索に当てはまる絵文字がないときは「見つかりませんでした」と出し、Enter を押しても何も入りません。最近使った絵文字は、新しく使ったものから順に並び、ページを読み込み直しても残りました。
最近使った: 👍いいね 🐈⬛黒ねこ
localStorage: ["👍","🐈⬛"]
黒ねこを使った後: 🐈⬛黒ねこ 👍いいね
検索欄に打つと、最近使ったの行: なし
読み込み直した後: 🐈⬛黒ねこ 👍いいね
壊れた値を入れて読み込み直した後: 最近使ったの行 なし
localStorage に JSON として読めない値を入れてから読み込み直すと、最近使った絵文字の行は出ず、エラーも起きませんでした。
最後に、ページを読み込み直して空になったテキストエリアでピッカーを開き、検索欄に「ねこ」と打って Enter を押しました。一覧の先頭の「ねこの顔」が入り、フォーカスはテキストエリアに戻りました。
「ねこ」と打って Enter: 値 "🐱" 選択 2-2 フォーカス テキストエリア ピッカー 閉 aria-expanded=false





