Reactで、入力した文字に色を付けるテキストエリアを作る方法

​​React​CSS,Playwright,TypeScript

textarea は装飾のない文字だけを編集する要素なので、中の文字の一部だけに色を付けることはできません。そこで、同じ文字を <mark> で色分けして描いた div を textarea の真後ろに置き、textarea の文字を透明にします。キャレットだけは caret-color で見えるようにしておけば、文字の入力や選択は textarea のまま使えます。

この記事では、#猫 のような #タグと、60文字を超えた部分に色を付ける入力欄を作りました。作り方そのものは短く済みますが、2つの要素の文字の位置が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 を操作して確かめました。Playwright はヘッドレスのときスクロールバーを隠すので、その指定を外し、スクロールバーが幅を取る状態で試しています。開発用のビルド(StrictMode あり)で入力とスクロールをしても、コンソールにエラーや警告は出ませんでした。日本語の変換中の入力は、Chrome DevTools Protocol の Input.imeSetComposition で再現しました。実際の IME、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

入力欄に「今日は #猫 の写真」と1文字ずつ打つと、「#猫」に水色の背景が付く。改行して「#ねこ #cat_photo」と打つと、2つとも水色になる。さらに文を打ち続けて60文字を超えると、超えた部分にピンクの背景が付き、入力欄の右下の文字数が「残り◯文字」から「2文字オーバー」に変わる。最後は「7文字オーバー」になる
文字を打つと #タグに色が付き、60文字を超えた部分に背景が付く様子。左上の説明、右上の回数、右下のキー、入力欄の下の「色が付いた #タグ」の数は、GIF 用に重ねた表示。#タグの数は増えた瞬間だけピンクにしている。文字数の表示は、上限を超えるとアプリの CSS でピンクの太字になり、超えた瞬間だけピンクの地に白い文字にしている。文字は日本語入力を通さずに直接入れ、3回目は1コマに2文字ずつ入れている

textarea の後ろに、色付きの文字を描く div を置く

部品の中身は、次の HighlightTextarea です。外側の div の中に、色付きの文字を描く div(以下、後ろの div)と textarea を重ねています。textarea は React の state で値を持つ書き方(value と onChange)にしてあり、同じ value から後ろの div の中身を作ります。値の持たせ方は、公式リファレンスにある、state で textarea の値を持つ例と同じです。

tsx
import { useRef, type ChangeEvent, type UIEvent } from 'react';
import { toSegments } from './segments';

type Props = {
  id: string;
  value: string;
  onChange: (value: string) => void;
  maxLength: number;
};

export function HighlightTextarea({ id, value, onChange, maxLength }: Props) {
  const backdropRef = useRef<HTMLDivElement>(null);

  // テキストエリアのスクロールを、後ろの div にそのまま写す
  const handleScroll = (e: UIEvent<HTMLTextAreaElement>) => {
    if (backdropRef.current) backdropRef.current.scrollTop = e.currentTarget.scrollTop;
  };

  return (
    <div className="hl-wrap">
      <div className="hl-backdrop" ref={backdropRef} aria-hidden="true">
        {toSegments(value, maxLength).map((s, i) =>
          s.kind === 'plain' ? s.text : <mark key={i} className={`hl-${s.kind}`}>{s.text}</mark>,
        )}
        {/* 最後が改行のとき、div は空の最終行を作らないので、空白を1つ足す */}
        {value.endsWith('\n') && ' '}
      </div>
      <textarea
        id={id}
        className="hl-input"
        value={value}
        onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
        onScroll={handleScroll}
        spellCheck={false}
      />
    </div>
  );
}

後ろの div には aria-hidden="true" を付けました。同じ文字が2回読み上げられないように、アクセシビリティのツリーから外すためです。Playwright でツリーを書き出したところ、後ろの div の文字は出てこず、入力欄として出たのはラベル付きの textbox 1つだけでした。

text
- text: 投稿(#タグに色が付きます)
- textbox "投稿(#タグに色が付きます)": "#猫 です"
- paragraph: 残り55文字

spellCheck={false} は、スペルチェックをしてよいかをブラウザに伝える spellcheck 属性を false にする指定です。この記事の例では、textarea 側のスペルチェックを止めています。文字が透明な textarea で、スペルチェックの波線がどう見えるかは試していません。

後ろの div の文字は、文字列のまま JSX に入れています。HTML の文字列を組み立てて dangerouslySetInnerHTML に渡すことはしません。<b>太字?</b> <img src=x onerror="window.__x=1"> #タグ と入力して後ろの div の中身を見たところ、タグは &lt;b&gt; のように文字として入り、b 要素も img 要素も作られませんでした。

色を付ける範囲を決める toSegments

文字列を「普通の文字」「#タグ」「上限を超えた部分」に分けるのは、次の toSegments です。上限で文字列を2つに分けてから、上限までの部分で #タグを探します。

ts
export type Segment = { text: string; kind: 'plain' | 'tag' | 'over' };

const TAG = /#[\p{L}\p{N}_]+/gu;

// 文字列を「普通の文字」「#タグ」「上限を超えた部分」に分ける
export function toSegments(text: string, maxLength: number): Segment[] {
  const chars = Array.from(text); // 絵文字を1文字として数える
  const head = chars.slice(0, maxLength).join('');
  const over = chars.slice(maxLength).join('');

  const segments: Segment[] = [];
  let last = 0;
  for (const m of head.matchAll(TAG)) {
    if (m.index > last) segments.push({ text: head.slice(last, m.index), kind: 'plain' });
    segments.push({ text: m[0], kind: 'tag' });
    last = m.index + m[0].length;
  }
  if (last < head.length) segments.push({ text: head.slice(last), kind: 'plain' });
  if (over) segments.push({ text: over, kind: 'over' });
  return segments;
}

いくつかの文字列で出力を見ると、次のようになりました。

text
"今日は #猫 です" 上限60 → ["plain:今日は ","tag:#猫","plain: です"]
"#cat_photo と #ねこ2 と #" 上限60 → ["tag:#cat_photo","plain: と ","tag:#ねこ2","plain: と #"]
"色: #fff は?" 上限60 → ["plain:色: ","tag:#fff","plain: は?"]
"abc#tag" 上限60 → ["plain:abc","tag:#tag"]
"🐈🐈🐈🐈🐈" 上限3 → ["plain:🐈🐈🐈","over:🐈🐈"]
"あいう#タグえお" 上限5 → ["plain:あいう","tag:#タ","over:グえお"]

Array.from で文字に分けているので、🐈 は1文字として数えられます。上限の位置で #タグの途中が切れた場合は、超えた部分の色が優先されます。#fff や abc#tag も #タグとして扱われるので、色の指定や URL の # を外したいときは正規表現を変えてください。

2つの要素の文字の位置をそろえる CSS

重ねる2つの要素では、文字の並びに関わる指定(幅、padding、枠線の太さ、フォント、行の高さ、折り返し)をすべて同じにします。共通の指定を1つのルールにまとめておくと、片方だけを変えてしまう心配が減ります。

css
.hl-wrap {
  position: relative;
}

/* 文字の並びを決める指定は、2つの要素で必ずそろえる */
.hl-backdrop,
.hl-input {
  box-sizing: border-box;
  width: 100%;
  margin: 0;
  padding: 10px 12px;
  border: 1px solid transparent;
  font: 16px/1.6 "Noto Sans CJK JP", sans-serif;
  letter-spacing: normal;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  scrollbar-gutter: stable; /* スクロールバーの幅を、両方で常に空けておく */
}

.hl-backdrop {
  position: absolute;
  inset: 0;
  overflow: hidden;
  color: #1f2a30;
  background: #fff;
}

.hl-input {
  position: relative; /* 後ろの div より手前に出す */
  display: block;
  height: 8.5em;
  resize: vertical;
  overflow-y: auto;
  color: transparent; /* 文字は後ろの div に描かせる */
  caret-color: #1f2a30; /* キャレットだけは見せる */
  background: transparent;
  border-color: #9aa5ab;
  border-radius: 6px;
}

.hl-input:focus {
  outline: 2px solid #00c8e8;
  outline-offset: 1px;
}

/* 色は背景と文字色だけで付ける。太さや大きさは変えない */
.hl-backdrop mark {
  color: inherit;
  background: none;
  border-radius: 3px;
}
.hl-backdrop .hl-tag {
  color: #067a98;
  background: #dff6fb;
}
.hl-backdrop .hl-over {
  background: #f8d3dc;
}

textarea の文字は color: transparent で見えなくし、caret-color でキャレットの色だけを付けます。後ろの div は position: absolute と inset: 0 で外側の div いっぱいに広げているので、textarea の高さが変わると一緒に伸び縮みします。textarea の style.height を 100px と 240px に書き換えて確かめたところ、どちらの高さでも、textarea と後ろの div の内側の高さ(clientHeight)は 98px と 238px で同じになり、文字のずれもありませんでした。ドラッグで高さを変える操作そのものは試していません。

ずれているかどうかは、見た目では分かりにくいので、画像を比べて確かめました。textarea の文字だけを黒で描いた画像と、後ろの div の文字だけを黒で描いた画像を撮り、色が大きく違うピクセルを数えています。上の CSS のままなら、360×137 ピクセルのうち違うピクセルは 0 でした。#タグを2つ含む文で、後ろの div の #タグだけを太字にすると 3,756、後ろの div の左右の padding を 1px 減らすと 2,428 になりました。

次の画像は、textarea の文字をピンクで見えるようにして、後ろの div の黒い文字と重ねたものです。

3つの入力欄を縦に並べた画像。1つ目はそろえた CSS で、ピンクの文字が黒い文字にぴったり重なっている。2つ目は後ろの div の #タグだけを太字にしたもので、「#cat_photo」から後ろの文字が右にずれ、ピンクと黒の文字が二重に見える。3つ目は最後の改行に空白を足さずにいちばん下までスクロールしたもので、行がほぼ1行ぶん上下にずれ、隣の行の文字と二重に見える
textarea の文字(ピンク)と後ろの div の文字(黒)を重ねた画像。ずれると文字が二重に見える。ピンクを textarea の文字だけに使うため、上限を超えた部分の背景は消して撮った

太字にした #タグが横に広がり、同じ行の後ろの文字がすべて右に押し出されています。色付きの部分を目立たせたいときも、太さ・大きさ・letter-spacing・padding のように文字の幅を変える指定は使わず、文字色と背景色だけで分けます。

スクロールと最後の改行でずれる

文字が入りきらず textarea がスクロールするときは、3つのことをそろえる必要があります。10行の文字(最後は改行)を入れ、マウスのホイールでいちばん下までスクロールして、scrollTop(どこまでスクロールしたか)と scrollHeight(中身全体の高さ)を比べました。

text
[完成形] textarea: scrollTop=167 scrollHeight=302 clientWidth=343 / div: scrollTop=167 scrollHeight=302 clientWidth=343
[完成形] 360x137 のうち、違うピクセル 0
[スクロールを写さない] textarea: scrollTop=167 scrollHeight=302 clientWidth=343 / div: scrollTop=0 scrollHeight=302 clientWidth=343
[スクロールを写さない] 360x137 のうち、違うピクセル 3238
[最後の改行に空白を足さない] textarea: scrollTop=167 scrollHeight=302 clientWidth=343 / div: scrollTop=142 scrollHeight=276 clientWidth=343
[最後の改行に空白を足さない] 360x137 のうち、違うピクセル 1413

1つ目は、スクロールの位置です。後ろの div は overflow: hidden で自分ではスクロールしないので、textarea の onScroll で scrollTop を写します。写さないと、textarea だけが下へ進み、後ろの div は先頭の行を出したままになりました。

2つ目は、最後の改行です。textarea は、最後が改行なら空の行を1行出します。後ろの div は空の最終行を作らないので、中身が1行ぶん(26px)低くなりました。後ろの div はそれ以上スクロールできず、scrollTop が 142 で止まって、文字が上下にずれます。最後が改行のときだけ空白を1つ足すと、高さがそろいました。

3つ目は、スクロールバーの幅です。スクロールバーが出ると、textarea の中で文字を置ける幅がその分だけ狭くなります。後ろの div にはスクロールバーが出ないので、そのままでは折り返しの位置が変わります。全角19文字のあとに #猫 を付けた行(あいうえお…たちつて#猫)を8行入れて比べました。出力の「全角20文字の行」は、この行(全角は「猫」を入れて20文字)のことです。

text
[全角20文字の行・完成形] textarea: scrollTop=321 scrollHeight=455 clientWidth=343 / div: scrollTop=321 scrollHeight=455 clientWidth=343
[全角20文字の行・完成形] 360x137 のうち、違うピクセル 0
[全角20文字の行・scrollbar-gutter なし] textarea: scrollTop=321 scrollHeight=455 clientWidth=343 / div: scrollTop=116 scrollHeight=250 clientWidth=358
[全角20文字の行・scrollbar-gutter なし] 360x137 のうち、違うピクセル 2733

scrollbar-gutter を指定しないと、textarea の clientWidth が 343px、後ろの div が 358px になりました。内側の幅(左右の padding を除いた幅)が 319px の textarea では1行に収まらず、334px ある後ろの div では収まるので、中身の高さが 455px と 250px まで離れています。両方に scrollbar-gutter: stable を指定すると、スクロールバーがないときも、overflow: hidden の後ろの div でも、同じ幅が空き、幅も高さもそろいました。

ポイント

文字の位置をそろえるには、CSS の指定に加えて、スクロールの位置、最後の改行、スクロールバーの幅の3つをそろえます。

MDN の scrollbar-gutter の説明によると、スクロールバーには場所を取るもの(classic)と、中身の上に重ねて表示されるもの(overlay)があり、overlay のスクロールバーでは stable を指定しても幅は空けられません。その場合は、どちらの要素にも幅が空かないので、そろったままになるはずです。overlay のスクロールバーを使う環境では、筆者は試していません。

使う側のコンポーネントと文字数の表示

使う側では、入力した文字列を useState で持ち、残りの文字数を出します。数え方は toSegments と同じ Array.from にそろえています。

tsx
import { useState } from 'react';
import { HighlightTextarea } from './HighlightTextarea';

const MAX = 60;

export function App() {
  const [text, setText] = useState('');
  const rest = MAX - Array.from(text).length;

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <label htmlFor="post">投稿(#タグに色が付きます)</label>
      <HighlightTextarea id="post" value={text} onChange={setText} maxLength={MAX} />
      <p className={rest < 0 ? 'count over' : 'count'}>
        {rest < 0 ? `${-rest}文字オーバー` : `残り${rest}文字`}
      </p>
    </form>
  );
}

textarea の maxlength 属性を使わないのは、上限を超えても入力はできるようにして、超えた部分を色で見せるためです。色だけでは超えたことが伝わりにくいので、「7文字オーバー」のように文字でも出しています。

変換中の文字を Input.imeSetComposition で入れたときも、onChange が呼ばれ、後ろの div に変換中の文字(#ねこ)が #タグとして入りました。確定すると #猫 に置き換わります。

​Posted by 86