Reactで、入力欄で「@」を打つと名前の候補を出す方法
テキストエリアで「@」を打ったときに名前の候補を出すには、入力やキャレットの移動のたびに、キャレットより前の文字を調べます。キャレットの直前が「@」とそれに続く文字で終わっていれば、その文字で候補を絞り込んで一覧を出します。候補を選んだら「@」から打ちかけの文字までを「@tama 」のような名前に置き換え、キャレットを名前の直後に戻します。
一覧を「@」の真下に出すには、テキストエリアと同じ見た目の div を画面の外に作り、そこで「@」の位置を測ります。キャレットの前を調べる処理、名前を入れてキャレットを戻す処理、一覧の位置を決める処理を、MentionTextarea という1つのコンポーネントにまとめました。
検証環境は 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 で変換し、Playwright で Chromium を操作して確かめました。日本語の変換中の入力は、Chrome DevTools Protocol の Input.imeSetComposition で変換中の文字を入れて再現しました。実際の IME、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

GIF の中の、左上の操作の説明、右上の操作の回数、右下のキーの表示、カーソルと押した位置の輪、入力欄の下の「候補 5件」などの表示は、撮影のために重ねたものです。キーボードで操作しているあいだは、カーソルを隠しています。操作で変わった表示は、その瞬間だけピンクにしています。
キャレットの前を調べて、候補を出すかを決める
候補を出すかどうかは、テキストエリアの値のうち、キャレットより前の部分だけで決めます。キャレットの位置は selectionStart で分かります。MDN の selectionStart の説明にあるとおり、文字を選んでいないときは、キャレットの位置を返します。
「@」を調べる関数、候補を絞り込む関数、送信するときにメンションを取り出す関数を、mention.ts にまとめました。最後の caretPosition() は、一覧の位置を決めるときに使います(後の節で説明します)。
export type User = { id: string; name: string };
export type Mention = { start: number; query: string };
// キャレットの直前が「@」と、それに続く空白以外の文字になっているかを調べる
// 「@」の前が英数字のときは開かない(cat@example.com のようなメールアドレスを除く)
// 日本語の文の中では「さん、@」のように、空白を入れずに打たれることがある
export function findMention(text: string, caret: number): Mention | null {
const m = /(?:^|[^A-Za-z0-9])@([^\s@]*)$/.exec(text.slice(0, caret));
if (!m) return null;
return { start: caret - m[1].length - 1, query: m[1] };
}
// id(ローマ字)か名前(ひらがな)の先頭が一致するものを、最大5件まで
export function filterUsers(users: User[], query: string): User[] {
const q = query.toLowerCase();
return users.filter((u) => u.id.startsWith(q) || u.name.startsWith(q)).slice(0, 5);
}
// 送信するときに、本文に書かれたメンションの id を取り出す
export function extractMentions(text: string, users: User[]): string[] {
const ids = new Set(users.map((u) => u.id));
const found = [...text.matchAll(/(?:^|[^A-Za-z0-9])@([a-z0-9_]+)/g)].map((m) => m[1]);
return [...new Set(found)].filter((id) => ids.has(id));
}
// textarea と同じ見た目の div を作り、指定した文字の位置(textarea の左上からの距離)を測る
const COPY = [
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing', 'wordSpacing',
'lineHeight', 'textTransform', 'textIndent', 'tabSize',
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
] as const;
export function caretPosition(el: HTMLTextAreaElement, index: number) {
const style = getComputedStyle(el);
const mirror = document.createElement('div');
for (const p of COPY) mirror.style[p] = style[p];
Object.assign(mirror.style, {
position: 'absolute',
visibility: 'hidden',
top: '0',
left: '0',
boxSizing: 'border-box',
width: `${el.clientWidth}px`, // padding を含み、枠線とスクロールバーを含まない幅
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
});
mirror.textContent = el.value.slice(0, index);
const marker = document.createElement('span');
marker.textContent = el.value.slice(index) || '.';
mirror.append(marker);
document.body.append(mirror);
const top = el.clientTop + marker.offsetTop - el.scrollTop;
const left = el.clientLeft + marker.offsetLeft - el.scrollLeft;
const lineHeight = parseFloat(style.lineHeight);
mirror.remove();
return { top, left, lineHeight };
}
findMention() の正規表現は、キャレットより前の文字列が、「@」と、それに続く空白・「@」以外の文字(0文字以上)で終わっているかを調べます。合っていれば、「@」の位置(start)と、その後ろの打ちかけの文字(query)を返します。検証では、「こんにちは @」と打った時点で5件すべての候補が出ました。続けて「t」を打つと「たま」「とら」の2件、「to」で「とら」の1件に絞られ、「tox」で一致するものがなくなると一覧は閉じました。
読点の直後の「@」でも候補を出す
「@」の前の文字の条件は、日本語の文で使うときに効いてきます。「@」の前を空白か文の先頭に限ると、「さん、@」のように読点の直後に打った「@」では、一覧が開きません。日本語では単語の間に空白を入れないので、ここで開かないのは不便です。
そこで、「@」の前が英数字のときだけ開かないようにしました。2つの条件で、開くかどうかを比べた結果です。
| キャレットの前の文字 | 前が空白か文の先頭なら開く | 前が英数字以外なら開く |
|---|---|---|
@ |
開く | 開く |
こんにちは @ |
開く | 開く |
さん、@ |
開かない | 開く |
ねこ@ |
開かない | 開く |
(@ |
開かない | 開く |
cat@ |
開かない | 開かない |
cat@example.com |
開かない | 開かない |
@tama (名前の後ろに空白) |
開かない | 開かない |
英数字の後ろの「@」で開かないので、cat@example.com のようなメールアドレスを打っている途中では、一覧が出ません。「@」の後ろに空白を打つと、そこで候補を探すのをやめます。
候補の絞り込みは、id(ローマ字)か名前(ひらがな)の先頭が一致するものにしています。日本語の変換中も、テキストエリアの値には変換中の文字が入ります。Chrome DevTools Protocol で「@」の後ろに変換中の「み」を入れたところ、値は「@み」になり、候補は「みけ」の1件に絞られました。
入力とキャレットの移動のたびに調べ直す
候補を出すかどうかは、文字を打ったときだけでなく、キャレットを動かしたときにも変わります。そこで、テキストエリアの onChange と onSelect の両方で、同じ update() を呼んでいます。React の共通コンポーネントのリファレンスにある onSelect の説明によると、React の onSelect は、文字を選んでいないとき(キャレットの移動)や、編集したときにも呼ばれます。
検証では、「@sh だよ」と打ってキャレットが末尾にあるときは、一覧は閉じていました。← を3回押してキャレットを「@sh」の直後に戻すと「しろ」の候補が出て、End を押すとまた閉じました。
update() の中では、Esc で閉じた「@」の位置を dismissedAt に覚えておき、同じ「@」では開き直さないようにしています。Esc で閉じたのに次の1文字でまた開くと、候補を使わずに「@」を書きたいときに邪魔になるためです。検証では「@m」で Esc を押すと閉じ、続けて「i」を打っても閉じたままでした。「@」を消して打ち直すと、また開きました。
選んだ名前を入れて、キャレットを名前の直後に戻す
候補を選んだら、「@」から打ちかけの文字の終わりまでを @tora のような文字列に置き換えます。最後に空白を付けるので、続けて文を打てます。置き換えた部分の後ろが空白で始まるときは、空白が2つ並ばないように1つ省きます。
置き換えたあとは、キャレットの位置に気をつけます。テキストエリアの value に新しい文字列を代入すると、キャレットは末尾に動きます。検証した Chromium で、キャレットを位置 3(先頭から3文字の後ろ)に置いたまま value に10文字の文字列を代入したところ、selectionStart は 10 になりました。戻す処理がないと、文の途中で名前を選んだときに、キャレットが文の終わりに飛んでしまいます。
そこで、置き換えたあとに置きたいキャレットの位置を nextCaret に入れておき、値が DOM に反映されたあと(画面に描かれる前に)、setSelectionRange() でその位置に戻します。戻す処理は useLayoutEffect に書きます。React の useLayoutEffect のリファレンスにあるとおり、ブラウザが画面を描き直す前に動くので、キャレットが末尾にある状態は画面に出ません。
検証では、「@ku と遊んだ」のキャレットを「@ku」の直後に戻して Tab で選ぶと、値は「@kuro と遊んだ」になり、キャレットは「@kuro 」の直後(位置 6)にありました。「こんにちは @t」で↓と Enter を押した場合も、「こんにちは @tora 」の直後にキャレットがあり、続けて打った「さん」はその後ろに入りました。
キャレットを戻したときに onSelect が呼ばれても、キャレットの直前は空白なので findMention() は null を返します。検証でも、名前を入れたあとの一覧は閉じたままでした。
候補の一覧を「@」の位置に出す
テキストエリアの中の文字の位置は、テキストエリアの要素からは直接分かりません。そこで caretPosition() では、テキストエリアと同じフォント・行の高さ・padding・幅の div を作って、見えない状態で画面に置きます。div には「@」より前の文字を入れ、その後ろに span を置いて、span の位置を測ります。span が始まる位置が、「@」の位置です。
幅は clientWidth に合わせます。MDN の説明にあるとおり、clientWidth は padding を含み、枠線とスクロールバーを含まない幅なので、div を border-box にしてこの幅を指定すれば、折り返す位置がテキストエリアと同じになります。white-space: pre-wrap と overflow-wrap: break-word も、テキストエリアの文字の並べ方に合わせるための指定です。
測った値には、テキストエリアの上の枠線の太さ(clientTop)を足し、テキストエリアの中のスクロール量(scrollTop)を引きます。一覧はその1行下に出します。一覧の position: absolute は、position: relative にした外側の div を基準にします。MDN の offsetTop の説明にあるとおり、span の offsetTop も、いちばん近い位置指定された祖先(ここでは測るための div)からの距離です。
幅 368px、padding 8px、枠線 1px、文字 16px、行の高さ 24px、4行のテキストエリアで、一覧の位置を測りました。一覧の幅は 200px です。
| キャレットの前の文字 | 一覧の上端 | 一覧の左端 |
|---|---|---|
@ |
33px | 9px |
| 「あいうえお」と、空白と「@」 | 33px | 93px |
| 「あいうえお…そ」の15文字と、空白と「@」 | 33px | 168px(右端で抑えた値) |
| 25文字と、空白と「@」(2行目に折り返す) | 57px | 77px |
改行して3行目の @ |
81px | 9px |
改行して7行目の @(中が 64px スクロール) |
113px | 9px |
「あいうえお」の後ろの「@」の 93px は、枠線と padding の 9px に、5文字分の 80px と空白の幅を足した位置です。折り返した場合は、2行目の「にぬねの @」の「@」の位置に出ました。7行目の場合は、スクロールした 64px が引かれ、見えている4行目の下に出ています。
右端に近い「@」では、一覧がテキストエリアからはみ出さないように、左端を「テキストエリアの幅 − 一覧の幅」(ここでは 168px)までに抑えています。表の3行目は、本来の位置ではなく、この抑えた値です。
キーボードの操作と、変換中の Enter
一覧が開いているあいだは、↓・↑で候補を選び、Enter か Tab で決め、Esc で閉じます。フォーカスはテキストエリアに置いたままにして、どの候補を選んでいるかは aria-activedescendant で伝えます。キーボードで候補を選ぶ仕組みは、以前の記事「Reactで、入力候補を出すコンボボックスをキーボードで操作する方法」と同じ考え方です。
ただし、テキストエリアには role="combobox" を付けられません。W3C の ARIA in HTML では、textarea に付けてよいロールは textbox だけで、それも勧めないとされています。一方、WAI-ARIA の textbox ロールは、aria-activedescendant と aria-autocomplete に対応しています。そこでロールは付けずに、aria-autocomplete="list"、aria-controls、aria-activedescendant の3つを付けました。MDN の aria-activedescendant の説明にも、フォーカスのある要素が textbox のときは、aria-controls で一覧を指すよう書かれています。スクリーンリーダーでどう読み上げられるかは、試していません。
Enter は、日本語の変換を確定するときにも押します。変換中の Enter で候補を決めてしまわないように、handleKeyDown() の最初で isComposing が true のときは何もしません。検証では、変換中の「み」がある状態で isComposing: true の keydown(スクリプトで作ったイベント)を送ると、preventDefault() は呼ばれず、候補は選ばれませんでした。変換を確定したあとに Enter を押すと、「@mike 」が入りました。なお、以前の記事でも触れたとおり、MDN の keydown イベントのページにある IME の説明では、変換の最初や最後のキーで isComposing が false のまま届く場合があるとされています。この記事のコードは isComposing だけを見ていて、実際の IME での動きは試していません。
マウスで選ぶときは、候補の onMouseDown で preventDefault() を呼び、押した時点でテキストエリアのフォーカスが外れないようにしています。テキストエリアの onBlur で一覧を閉じているためです。以前の記事「Reactで、入力候補を出すコンボボックスをキーボードで操作する方法」の検証では、この1行を消すと、クリックしても候補は選ばれず、フォーカスが body 要素に移りました。この記事の検証では、「くろ」をクリックすると「@kuro 」が入り、フォーカスはテキストエリアに残っていました。
MentionTextarea のコード
ここまでの処理をまとめたコンポーネントです。
import { useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from 'react';
import { caretPosition, filterUsers, findMention, type Mention, type User } from './mention';
type Props = Omit<ComponentProps<'textarea'>, 'value' | 'onChange'> & {
users: User[];
value: string;
onChange: (value: string) => void;
};
const LIST_WIDTH = 200;
export function MentionTextarea({ users, value, onChange, ...rest }: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
const listId = useId();
const [mention, setMention] = useState<Mention | null>(null);
const [active, setActive] = useState(0);
const [pos, setPos] = useState({ top: 0, left: 0 });
const lastKey = useRef(''); // 前回の「@の位置:検索文字列」
const dismissedAt = useRef<number | null>(null); // Esc で閉じた「@」の位置
const nextCaret = useRef<number | null>(null); // 候補を入れたあとに置くキャレットの位置
const candidates = mention ? filterUsers(users, mention.query) : [];
const open = candidates.length > 0;
// 入力やキャレットの移動のたびに、キャレットの前を調べ直す
function update(el: HTMLTextAreaElement) {
const caret = el.selectionStart;
const found = caret === el.selectionEnd ? findMention(el.value, caret) : null;
if (found?.start !== dismissedAt.current) dismissedAt.current = null;
const m = found && found.start !== dismissedAt.current ? found : null;
const key = m ? `${m.start}:${m.query}` : '';
if (key !== lastKey.current) {
lastKey.current = key;
setActive(0); // 検索文字列が変わったら、先頭の候補を選び直す
}
setMention(m);
if (m) {
const p = caretPosition(el, m.start);
setPos({ top: p.top + p.lineHeight, left: Math.min(p.left, el.offsetWidth - LIST_WIDTH) });
}
}
function select(user: User) {
if (!mention) return;
const end = mention.start + 1 + mention.query.length; // 「@」と検索文字列の終わり
const insert = `@${user.id} `;
const rest = value.slice(end);
nextCaret.current = mention.start + insert.length;
onChange(value.slice(0, mention.start) + insert + (rest.startsWith(' ') ? rest.slice(1) : rest));
setMention(null);
}
// 値を書き換えるとキャレットが末尾に動くので、入れた名前の直後に戻す
useLayoutEffect(() => {
if (nextCaret.current === null) return;
ref.current!.setSelectionRange(nextCaret.current, nextCaret.current);
nextCaret.current = null;
}, [value]);
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (!open || e.nativeEvent.isComposing) return; // 変換中のキーは IME に任せる
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActive((i) => (i + 1) % candidates.length);
break;
case 'ArrowUp':
e.preventDefault();
setActive((i) => (i - 1 + candidates.length) % candidates.length);
break;
case 'Enter':
case 'Tab':
e.preventDefault();
select(candidates[active]);
break;
case 'Escape':
e.preventDefault();
dismissedAt.current = mention!.start;
setMention(null);
break;
}
}
return (
<div style={{ position: 'relative' }}>
<textarea
{...rest}
ref={ref}
value={value}
onChange={(e) => {
onChange(e.target.value);
update(e.target);
}}
onSelect={(e) => update(e.currentTarget)}
onKeyDown={handleKeyDown}
onBlur={() => setMention(null)}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
aria-activedescendant={open ? `${listId}-${active}` : undefined}
/>
{open && (
<ul
id={listId}
role="listbox"
aria-label="メンションの候補"
style={{ position: 'absolute', top: pos.top, left: pos.left, width: LIST_WIDTH }}
className="mention-list"
>
{candidates.map((u, i) => (
<li
key={u.id}
id={`${listId}-${i}`}
role="option"
aria-selected={i === active}
onMouseDown={(e) => e.preventDefault()} // 押した時点で textarea のフォーカスを外さない
onClick={() => select(u)}
onMouseEnter={() => setActive(i)}
>
{u.name} <span className="mention-id">@{u.id}</span>
</li>
))}
</ul>
)}
</div>
);
}
使う側は、候補にする名前の一覧と、値と onChange を渡します。送信するときに extractMentions() で本文から id を取り出せば、通知を送る相手を決めるときなどに使えます。
import { useState, type FormEvent } from 'react';
import { MentionTextarea } from './MentionTextarea';
import { extractMentions, type User } from './mention';
const USERS: User[] = [
{ id: 'tama', name: 'たま' },
{ id: 'tora', name: 'とら' },
{ id: 'mike', name: 'みけ' },
{ id: 'kuro', name: 'くろ' },
{ id: 'shiro', name: 'しろ' },
];
export function App() {
const [text, setText] = useState('');
const [sent, setSent] = useState<string[] | null>(null);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSent(extractMentions(text, USERS));
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="comment">コメント</label>
<MentionTextarea id="comment" users={USERS} value={text} onChange={setText} rows={4} placeholder="@ で名前の候補が出ます" />
<button type="submit">送信</button>
<p role="status">{sent && `メンション:${sent.length ? sent.join('、') : 'なし'}`}</p>
</form>
);
}
extractMentions() は、候補の一覧にある id だけを、重複を除いて返します。「@tama と @kuro と @nobody へ。cat@example.com @tama、@mike」を送信すると、結果は「tama、kuro、mike」でした。一覧にない @nobody と、メールアドレスの中の「@」は入らず、2回書いた @tama は1回になっています。





