背景
コメント入力欄に「@ユーザー」や「#場所名」のようなメンションを埋め込み、
その部分だけ色を変えてリンクっぽく見せたい、という要件はよくある。
しかし<textarea>はプレーンテキストしか扱えず、文字列の一部だけ色を変えることができない。
今回、実際にこれを実装する過程で「オーバーレイ技法」というテクニックにたどり着き、あわせて
2つの罠を踏んだので、まとめておく。
環境
- React 18 + TypeScript
- Tailwind CSS v4
1. オーバーレイ技法でメンション部分だけ色を変える
<textarea>自体はモノクロのテキストしか描画できない。そこで、以下の2層構成にする。
-
層1(表示専用): 通常の
<div>。メンション部分だけ<span>で色付けして描画する -
層2(入力受け付け): 実物の
<textarea>。文字色を透明にし、キャレット(カーソル)の色だけ指定する
<div className="relative">
{/* 層1: 表示専用。メンション部分だけ色付け */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 whitespace-pre-wrap break-words px-3 py-1.5 text-[16px] leading-6"
>
{renderHighlighted(text, mentions)}
</div>
{/* 層2: 実際の入力欄。文字は透明、キャレットだけ見せる */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="relative z-10 block w-full whitespace-pre-wrap break-words border-none bg-transparent px-3 py-1.5 text-[16px] leading-6 text-transparent outline-none"
style={{ caretColor: 'black' }}
/>
</div>
ポイントは以下の3つ。
- 層1は
pointer-events-noneにして、クリックやキー入力を層2(実際のtextarea)に透過させる - 層2は
relative z-10で層1より手前に置く。こうしないと、ネイティブのキャレット(点滅カーソル)が
層1に隠れて見えなくなる -
両層の
padding・font-size・line-height・white-spaceを完全に一致させる。 1pxでもズレると、
層1の色付き文字と、層2の(見えない)実際の文字位置がズレてしまい、クリックした場所と違う位置に
カーソルが飛んでいるように見える
フォントサイズは要注意で、text-baseのようなrem相対のユーティリティクラスだと、親要素のフォント
サイズ設定(例えばモバイル対応でルートのフォントサイズが変わる設計の場合)によって両層で微妙に
ズレることがある。両層ともtext-[16px]のように絶対値で明示するのが安全。
横スクロールが発生する場合は、層2のonScrollで層1のscrollLeft/scrollTopを同期する必要もある。
onScroll={(e) => {
overlayRef.current!.scrollLeft = e.currentTarget.scrollLeft
overlayRef.current!.scrollTop = e.currentTarget.scrollTop
}}
2. event.isComposingだけで判定するとIME変換確定を誤検知する
日本語入力(IME)では、変換を確定するために押すEnterキーと、本当に改行・送信したいときのEnterキーを
区別する必要がある。よくあるのが「Shift+Enterで送信、変換確定中のEnterでは送信しない」という実装で、
KeyboardEvent.isComposingを使って判定するのが定番とされている。
// 一見正しそうだが、環境によって取りこぼす
if (event.key === 'Enter' && event.shiftKey && !event.isComposing) {
submit()
}
これをVitest + Testing Libraryでテストしたところ、fireEvent.keyDown(el, { isComposing: true })で
明示的にisComposingを渡しても、Reactの合成イベント側に正しく伝播せず、変換確定中のはずのキー操作が
送信扱いになってしまう不具合が発生した。
対策: isComposingプロパティを直接読むのではなく、compositionstart/compositionendイベントで
自前のフラグを持ち、そちらを判定に使う。
const isComposingRef = useRef(false)
<textarea
onCompositionStart={() => { isComposingRef.current = true }}
onCompositionEnd={() => { isComposingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter' && event.shiftKey && !isComposingRef.current) {
submit()
}
}}
/>
isComposingはブラウザ間でも挙動差があることが知られているため、テスト環境に限らず本番コードとしても
自前追跡の方が確実。
3. rounded-fullな入力欄は、複数行になった瞬間に丸くなる
1行のピル型(カプセル型)入力欄によくrounded-full(border-radius: 9999px)を使う。横長・低い高さの
ボックスに対しては綺麗な半円の両端を持つピルに見える。
<div className="rounded-full border ...">
<textarea ... />
</div>
しかし、このボックスの中身が複数行に伸びて高さが増えると、border-radius: 9999pxは「実際にとりうる
最大の丸み」を意味するため、正方形に近づくにつれて見た目がほぼ円になってしまう。長文を入力しただけで
入力欄全体が丸いシルエットに包まれ、レイアウトが崩壊しているように見える不具合として現れた。
対策: 高さが可変(複数行に伸びる可能性がある)な要素にはrounded-fullを使わず、rounded-2xlのような
固定の角丸を使う。あわせて、伸びた分だけ下に隣接するボタン等がある場合は、items-centerではなく
items-endにして常に下揃えにすると、送信ボタンなどが浮いて見えなくなる。
<div className="flex items-end gap-2">
<div className="relative flex-1 rounded-2xl border ...">
{/* textarea + overlay */}
</div>
<button>送信</button>
</div>
まとめ
- textareaで部分的な色分けが必要なら、「透明textarea + 表示専用div」の二層構成(オーバーレイ技法)が使える
- IME変換確定の判定は
event.isComposingだけに頼らず、compositionstart/compositionendで自前追跡する - 高さが可変な要素に
rounded-fullは使わない。伸びると円になる