Reactで、入力に合わせて高さが伸びるテキストエリアを作る方法
テキストエリアの高さを中身に合わせるには、CSS の field-sizing: content を指定します。これだけで、改行したり、長い文が折り返したりした分だけ高さが伸びます。行数の下限と上限は、min-height と max-height に lh 単位(1行の高さ)で書けば決められます。
field-sizing が使えないブラウザに向けては、useLayoutEffect の中で scrollHeight を測り、高さを設定する処理を足します。2つの書き方を、AutoResizeTextarea という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 を操作して高さを測りました。field-sizing が使えないブラウザは実際には用意できなかったため、同じ Chromium で CSS.supports() が false を返すようにし、field-sizing: fixed を強制して再現しています。Firefox と Safari、スマートフォンのブラウザ、日本語の変換中の入力は試していません。

GIF の中の「入力欄の高さ」の表示、左上の操作の説明と回数、キーの表示、カーソルと押した位置の輪は、撮影のために重ねたものです。キーボードで操作しているあいだは、カーソルを隠しています。高さの表示は、高さが変わった瞬間だけピンクにしています。
field-sizing: content で、高さを中身に合わせる
MDN の field-sizing の説明によると、field-sizing: content を指定したフォームの部品は、中身に合わせて大きさが変わります。テキストエリアの場合は、幅が広げられなくなると、行が増えた分だけ高さが伸びます。高さの上限に届くと、そこからはスクロールバーで中を見る形になります。
使えるブラウザは、MDN の互換性の表(browser-compat-data)によると、Chrome 123、Firefox 152、Safari 26.2 以降です。これより古いブラウザでは、この指定だけでは高さが伸びないため、別の方法が要ります。
注意したいのは、field-sizing: content を指定すると、rows 属性が効かなくなることです(MDN の同じページに書かれています)。空のときの高さを決めるには、min-height を指定します。
行数の下限と上限は lh 単位で決める
lh は、MDN の長さの単位(<length>)の説明にあるとおり、その要素の line-height を長さにした単位です。min-height: 2lh と書けば「2行分」、max-height: 5lh と書けば「5行分」になります。
ただし、高さの指定が何の高さを指すかは、box-sizing で変わります。MDN の box-sizing の説明にあるとおり、border-box では padding と枠線を含む高さになるので、2lh が行数ちょうどになりません。そこでコンポーネントでは box-sizing: content-box にして、文字が入る部分の高さとして指定しています。
下限2行、上限5行、line-height が 24px(16px の 1.5 倍)、上下の padding が 8px、枠線が 1px の入力欄で、行数ごとの高さを測りました。入力欄の高さは、枠線まで含めた offsetHeight です。
| 打った行数 | 入力欄の高さ | 中のスクロール |
|---|---|---|
| 0〜2行 | 66px | なし |
| 3行 | 90px | なし |
| 4行 | 114px | なし |
| 5行 | 138px | なし |
| 6行、7行 | 138px | あり |
66px は、2行分の 48px に、上下の padding(16px)と枠線(2px)を足した高さです。1行増えるごとに 24px ずつ伸び、5行で止まりました。改行せずに長い文を打った場合も、折り返して2行を超えた分だけ伸びました。
lh を使うときは、line-height が normal にならないようにします。検証では、入力欄の style に font: '16px sans-serif' のような一括指定を渡すと、lineHeight: 1.5 を一緒に渡していても、計算後の line-height が normal になりました。MDN の font の説明にあるとおり、font は値に書かなかった line-height も初期値の normal に戻すので、lineHeight より後に設定されると、その指定を上書きします。このときは lh が 18px、実際の1行が約 21px と合わなくなり、5行目を打った時点で中のスクロールが出ました。文字の大きさは fontSize と fontFamily で個別に指定すると、こうなりません。
AutoResizeTextarea のコード
field-sizing が使えるかどうかは、CSS.supports() で確かめます。使えるときは CSS に任せて何もせず、使えないときだけ、高さを測って設定する処理を動かします。
import { useLayoutEffect, useRef, type ComponentProps } from 'react';
// field-sizing: content が使えるブラウザでは、高さの計算を CSS に任せる
const supportsFieldSizing =
typeof CSS !== 'undefined' && CSS.supports('field-sizing', 'content');
// 使えないブラウザ用:いったん高さを戻し、中身の高さ(scrollHeight)に合わせる
function fitHeight(el: HTMLTextAreaElement) {
const { paddingTop, paddingBottom } = getComputedStyle(el);
el.style.height = 'auto';
// content-box なので、scrollHeight に含まれる上下の padding を引く
el.style.height = `${el.scrollHeight - parseFloat(paddingTop) - parseFloat(paddingBottom)}px`;
}
type Props = Omit<ComponentProps<'textarea'>, 'value' | 'onChange' | 'rows'> & {
value: string;
onChange: (value: string) => void;
minRows?: number;
maxRows?: number;
};
export function AutoResizeTextarea({ value, onChange, minRows = 2, maxRows = 8, style, ...rest }: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
// 値が変わるたびに測り直す(画面に描かれる前に高さを決める)
useLayoutEffect(() => {
if (!supportsFieldSizing) fitHeight(ref.current!);
}, [value]);
// 幅が変わると折り返しの位置が変わるので、幅が変わったときも測り直す
useLayoutEffect(() => {
if (supportsFieldSizing) return;
const el = ref.current!;
let width = el.clientWidth;
const observer = new ResizeObserver(() => {
if (el.clientWidth === width) return; // 自分で高さを変えたときは何もしない
width = el.clientWidth;
fitHeight(el);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<textarea
{...rest}
ref={ref}
rows={minRows}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
boxSizing: 'content-box',
lineHeight: 1.5,
minHeight: `${minRows}lh`,
maxHeight: `${maxRows}lh`,
fieldSizing: 'content',
resize: 'none',
...style,
}}
/>
);
}
style の fieldSizing は、どちらの場合も書いたままにしています。TypeScript の型(@types/react 19.3.0)にも fieldSizing があり、tsc の型チェックは通りました。
使う側は、ふつうの制御されたテキストエリアと同じように、値と onChange を渡します。次の例では、送信したら値を空に戻しています。値が空になると、高さも2行分の 66px に戻りました。
import { useState, type FormEvent } from 'react';
import { AutoResizeTextarea } from './AutoResizeTextarea';
export function App() {
const [text, setText] = useState('');
const [sent, setSent] = useState('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSent(text);
setText(''); // 送信したら空に戻す(高さも最小に戻る)
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="comment">コメント</label>
<AutoResizeTextarea
id="comment"
value={text}
onChange={setText}
minRows={2}
maxRows={5}
placeholder="コメントを入力"
style={{ display: 'block', width: '100%', padding: '8px', border: '1px solid #9aa5ab', borderRadius: '6px', fontSize: '16px', fontFamily: 'inherit' }}
/>
<button type="submit">送信</button>
<p role="status">{sent && `送信しました(${sent.split('\n').length}行)`}</p>
</form>
);
}
field-sizing が使えないブラウザでは scrollHeight を測る
fitHeight() は、まず height を auto にして、入力欄を本来の高さに戻します。そのうえで scrollHeight を読み、中身がすべて収まる高さを求めます。
MDN の scrollHeight の説明のとおり、scrollHeight は padding を含み、枠線を含みません。content-box の height は padding を含まない高さなので、上下の padding を引いてから設定します。引かずに設定すると、計算のうえでは上下の padding の分(この例では 16px)だけ高くなります。
先に height を auto にするのは、行を消したときに縮められるようにするためです。MDN の同じページにあるとおり、中身が収まっているときの scrollHeight は clientHeight と同じ値です。高さを設定したまま測ると、いまの高さより小さい値が得られません。auto にしたときの高さは rows で決まり、ここでは rows={minRows} で下限の行数にしています。MDN の textarea の説明にあるとおり、rows を省くと2行になります。上限は、field-sizing を使う場合と同じく max-height に任せます。
測る処理は useLayoutEffect に書きます。React の useLayoutEffect のリファレンスにあるとおり、useLayoutEffect の処理は、ブラウザが画面を描き直す前に動きます。高さを直す前の入力欄が、画面に描かれずに済みます。
field-sizing を使えないブラウザを再現して測ると、入力欄の高さは、上の表とすべて同じになりました。style.height に設定された値は、2行までは 48px、3行で 72px、5行で 120px です。6行目では 144px が設定されましたが、max-height の 120px で止まり、入力欄の高さは 138px のままでした。
幅が変わったときも測り直す
画面の幅が変わると、長い文の折り返す位置が変わり、必要な高さも変わります。field-sizing: content では、ブラウザが自動で高さを合わせ直します。一方、scrollHeight を測る書き方では、値が変わったときしか測らないので、幅の変化には別に対応が必要です。
コンポーネントでは、ResizeObserver で入力欄の大きさの変化を受け取り、幅が変わったときだけ fitHeight() を呼んでいます。fitHeight() で高さを変えたときにも通知が来るので、幅が前と同じなら何もしないようにしています。
長い文を入れた入力欄で、フォームの幅を 360px から 220px に狭めて比べました。
| 書き方 | 360px のとき | 220px に狭めたあと |
|---|---|---|
field-sizing: content |
90px | 114px |
scrollHeight を測る(幅の変化で測り直す) |
90px | 114px |
scrollHeight を測る(幅の変化で測り直さない) |
90px | 90px(中のスクロールあり) |
測り直さない場合は、折り返しで4行になったのに高さが3行分(90px)のままで、中のスクロールが出ました。次に文字を打つと値が変わるので測り直されますが、それまでは隠れた行があることに気づきにくくなります。





