Reactで、行番号付きのテキストエリアを作る方法
textarea には行番号を出す機能がないので、行番号を縦に並べた div を textarea の左に置きます。番号は入力した文字列の改行の数から作り、textarea をスクロールしたら、その分だけ番号の列も動かします。折り返しを止めて「1行=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 がヘッドレスのときに付ける --hide-scrollbars を外し、スクロールバーが幅を取る状態で試しています。開発用のビルド(StrictMode あり)で操作しても、コンソールにエラーや警告は出ませんでした。Firefox と Safari、スマートフォンのブラウザ、日本語の変換中の入力、スクリーンリーダーでの読み上げ、数千行を超える文字列での速さは試していません。
![6行の設定を入れた入力欄で、Enter を押して「file = "app.log"」「rotate = 7」、空の行、「[cache]」と書き足すと、行番号が7から10まで増え、入力欄がスクロールすると番号の列も一緒に上に動く。↑ を3回押すと、太字の番号が10から7へ移る。ホイールで上にスクロールすると、番号の列も1行目まで戻る。2行目をクリックすると、太字の番号が2になる](https://neko-note.org/wp-content/uploads/2026/11/react-line-number-textarea-line-number-textarea.gif)
行番号の列と textarea を横に並べる部品
部品の中身は、次の LineNumberTextarea です。外側の div の中に、行番号の列と textarea を横に並べています。textarea は、公式リファレンスにある、state で textarea の値を持つ例と同じく、value と onChange で値を管理しています。
import { useEffect, useRef, useState, type CSSProperties, type UIEvent } from 'react';
type Props = {
id: string;
value: string;
onChange: (value: string) => void;
rows?: number;
};
// キャレットがある行の番号(1から数える)
function caretLine(el: HTMLTextAreaElement) {
const pos = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd;
return el.value.slice(0, pos).split('\n').length;
}
export function LineNumberTextarea({ id, value, onChange, rows = 8 }: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const numbersRef = useRef<HTMLDivElement>(null);
const [current, setCurrent] = useState(1);
const count = value.split('\n').length;
// キャレットが動くたびに、キャレットのある行を求め直す
useEffect(() => {
const el = textareaRef.current!;
const update = () => setCurrent(caretLine(el));
el.addEventListener('selectionchange', update);
return () => el.removeEventListener('selectionchange', update);
}, []);
// textarea のスクロールに合わせて、行番号の列を上にずらす
const handleScroll = (e: UIEvent<HTMLTextAreaElement>) => {
const top = e.currentTarget.scrollTop;
if (numbersRef.current) numbersRef.current.style.transform = `translateY(${-top}px)`;
};
return (
<div className="ln-wrap" style={{ '--digits': String(count).length } as CSSProperties}>
<div className="ln-gutter" aria-hidden="true">
<div ref={numbersRef}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className={i + 1 === current ? 'ln-current' : undefined}>
{i + 1}
</div>
))}
</div>
</div>
<textarea
ref={textareaRef}
id={id}
className="ln-input"
value={value}
rows={rows}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
/>
</div>
);
}
行の数は value.split('\n').length で数えます。空の文字列でも1になるので、何も入力していない状態でも「1」が出ます。
行番号の列には aria-hidden="true" を付けて、アクセシビリティのツリーから外しています。「a = 1」「b = 2」の2行を入れて Playwright でツリーを書き出すと、番号は出てこず、ラベル付きの textbox が1つだけになりました。
- text: 設定ファイル
- textbox "設定ファイル": a = 1 b = 2
- button "クリア"
使う側では、文字列を useState で持って渡すだけです。
import { useState } from 'react';
import { LineNumberTextarea } from './LineNumberTextarea';
export function App() {
const [code, setCode] = useState('');
return (
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="code">設定ファイル</label>
<LineNumberTextarea id="code" value={code} onChange={setCode} />
<button type="button" onClick={() => setCode('')}>
クリア
</button>
</form>
);
}
Enter で20行まで書き足し、textarea がスクロールした状態で「クリア」を押すと、textarea の scrollTop が 0 に戻り、番号の列も translateY(0px) に戻りました。番号は「1」だけになり、太字も1行目に移ります。
行の高さをそろえ、折り返しを止める CSS
番号と行の位置を合わせるために、font(大きさと行の高さ)と上下の padding を、行番号の列と textarea で同じにします。共通の指定は1つのルールにまとめました。
.ln-wrap {
display: flex;
border: 1px solid #9aa5ab;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.ln-wrap:focus-within {
outline: 2px solid #00c8e8;
outline-offset: 1px;
}
/* 行の高さを決める指定は、行番号の列と textarea で必ずそろえる */
.ln-gutter,
.ln-input {
margin: 0;
padding-block: 8px;
font: 14px/1.6 "Noto Sans Mono CJK JP", monospace;
}
.ln-gutter {
flex: none;
contain: size; /* 行番号の数で高さが伸びないようにし、高さは textarea に合わせる */
overflow: hidden;
width: calc(var(--digits) * 1ch);
min-width: 2ch;
padding-inline: 8px;
text-align: right;
color: #9aa5ab;
background: #eefafd;
user-select: none;
}
.ln-current {
color: #067a98;
font-weight: bold;
}
.ln-input {
flex: 1;
min-width: 0;
padding-inline: 8px;
border: 0;
outline: none;
resize: vertical;
white-space: pre;
overflow: auto;
color: #1f2a30;
}
contain: size は、中身を無視して要素の大きさを決める指定です。これがないと、flex で横に並べた textarea が、番号の列の高さまで引き伸ばされます。30行を入れて比べると、contain: size があるときの textarea の高さは 195px(rows={8} の8行ぶん)、外すと 688px で、30行がすべて見える高さまで伸びました。
番号の列の幅は、桁数を CSS 変数 --digits で渡し、ch 単位で決めています。ch は、そのフォントの「0」の幅です。行数を変えて、いちばん大きい番号の幅と、列の内側の幅を比べました。
| 行数 | 最後の番号の幅 | 列の内側の幅 |
|---|---|---|
| 9行 | 14px | 14px(min-width: 2ch) |
| 99行 | 14px | 14px |
| 100行 | 21px | 21px |
| 1000行 | 28px | 28px |
どの行数でも、番号は列の中に収まりました。99行から100行に増えた瞬間に、列が1文字ぶん広がり、そのぶん textarea が狭くなります。
番号の列には user-select: none も付けました。ページの左上から「クリア」のボタンの下までドラッグして選択すると、選択された文字列は "設定ファイル\n\nクリア" で、番号は入りませんでした。
折り返しは、textarea に white-space: pre を指定して止めています。MDN の textarea の説明では、wrap="off" は標準ではない値で、見た目を white-space: pre にするものとされています。そこで、この記事では属性を使わず、CSS で指定しました。長い行のある4行を入れると、textarea の中は4行のままで、横に 1,017px はみ出してスクロールできる状態になりました。
番号の列は scrollTop ではなく transform で動かす
番号の列の scrollTop に、textarea と同じ値を入れて動かす書き方もあります。この書き方では、横にはみ出す行があると、いちばん下までスクロールしたときに番号が15px ずれました。
30行のうち5行目だけが横にはみ出す文字列を入れ、ホイールでいちばん下までスクロールして比べた結果が次のとおりです。
[scrolltop・long] textarea: scrollTop=508 scrollHeight=688 clientHeight=180
[scrolltop・long] 行番号の列: scrollTop=493 scrollHeight=688 clientHeight=195 transform=なし
[scrolltop・long] 行番号と行の上端の差(最大): 15px、列のいちばん下に見える行番号: 30
textarea の下には横のスクロールバーが出るので、中身が見える高さ(clientHeight)は 180px になります。番号の列にはスクロールバーがなく、195px のままです。中身の高さはどちらも 688px なので、スクロールできるのは textarea が 508px まで、番号の列は 493px までです。番号の列は 493px で止まり、差の 15px だけ番号が下にずれました。横にはみ出す行がない30行では、どちらも 493px までスクロールでき、ずれは出ませんでした。
transform: translateY() で動かす完成形では、番号の列の scrollTop の上限に関係なく、同じ文字列でも translateY(-508px) まで動きました。行番号と行の上端の差は、いちばん大きいところでも 0.3px です。
[final・long] textarea: scrollTop=508 scrollHeight=688 clientHeight=180
[final・long] 行番号の列: scrollTop=0 scrollHeight=688 clientHeight=195 transform=translateY(-508px)
[final・long] 行番号と行の上端の差(最大): 0.3px、列のいちばん下に見える行番号: 30

translateY の値は、React の state を通さずに、ref で取った要素の style.transform に直接書いています。Enter で20行まで書き足したときも、textarea が自分でスクロールした分(scrollTop=260)だけ、番号の列も translateY(-260px) に動いていました。
折り返しを止めないと、番号が行からずれる
上の画像の3つ目は、white-space: pre を外して、textarea の中で長い行を折り返させたものです。番号は改行の数(4つ)しか出ないのに、見た目の行は8行あります。折り返した2行目の続きの横に「3」「4」が並び、本当の3行目と4行目には番号が付きません。
この記事のコードは改行の数だけ番号を出すので、折り返す入力欄にはそのままでは使えません。コードを書く入力欄を想定して、折り返しを止めています。
キャレットのある行は selectionchange で求める
キャレットのある行の番号は、キャレットの位置より前にある改行の数に1を足して求めます。キャレットの位置には、ふだんは選択の終わり(selectionEnd)を使います。selectionDirection が "backward"(後ろから前へ選択している)のときだけ、選択の始まり(selectionStart)を使います。3行目の末尾から Shift+↑ で1行目まで選択すると、太字は1行目になりました。
React の onSelect と onChange で求め直す書き方も試しました。公式リファレンスの onSelect の説明では、選択が変わったあとに呼ばれ、選択が空のときや編集のときにも呼ばれるとされています。入力、↑ キー、Ctrl+End、Shift+↑ での選択では、正しい行になりました。ところが、同じように3行目の末尾から1行目まで選択したあと、選択範囲の中の2行目をクリックすると、キャレットは2行目に移ったのに、太字は1行目のまま残りました。
--- キャレットのある行(onselect)---
Shift+↑ を2回(後ろから前へ選択): 1
選択範囲の中の2行目をクリック: 1 (キャレットは2行目)
そこで、textarea で発生する selectionchange イベントを受け取るようにしました。useEffect の中で、ref で取った textarea に addEventListener で登録しています。同じ操作をすると、太字も2行目に移りました。
--- キャレットのある行(final)---
Shift+↑ を2回(後ろから前へ選択): 1
選択範囲の中の2行目をクリック: 2 (キャレットは2行目)
textarea で selectionchange が発生するのは、MDN の互換性の表によると Chrome 127、Firefox 92、Safari 18 からです。それより前の Chrome と Safari では、このイベントは textarea ではなく document で発生します。この記事のコードは textarea に登録しているので、対象はそれ以降のブラウザです。





