Reactで、入力に合わせて高さが伸びるテキストエリアを作る方法

​​React​CSS,Playwright,TypeScript

テキストエリアの高さを中身に合わせるには、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、スマートフォンのブラウザ、日本語の変換中の入力は試していません。

2〜5行の入力欄に1行ずつ打って Enter で改行すると、2行目までは高さが 66px のまま、3行目から 90px、114px、138px と伸び、6行目では高さは 138px のまま「上限。中をスクロール」と表示される。送信すると入力欄が空になり、高さが 66px に戻る
行が増えると入力欄が伸び、5行で止まり、送信すると元の高さに戻る様子

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 に任せて何もせず、使えないときだけ、高さを測って設定する処理を動かします。

tsx
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 に戻りました。

tsx
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)のままで、中のスクロールが出ました。次に文字を打つと値が変わるので測り直されますが、それまでは隠れた行があることに気づきにくくなります。

​Posted by 86