Reactで、長い文章を「続きを読む」で折りたたむ方法

​​React​CSS,Playwright,ResizeObserver,TypeScript,useLayoutEffect,アクセシビリティ

長い文章を決まった行数で切るのは、CSS の -webkit-line-clamp に任せます。React の側では、文章が本当にはみ出しているかを scrollHeight と clientHeight で測り、はみ出しているときだけ「続きを読む」ボタンを出します。行数で切るので、画面の幅が変わっても、いつも3行目の終わりで切れます。

もう1つ、折りたたんだ部分にリンクがあると、Tab キーで見えないリンクにフォーカスが移ってしまいます。この記事のコンポーネントでは、文章の中にフォーカスが入ったら広げるようにしました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。StrictMode を付けた開発ビルドでも同じ操作をしたところ、出力は本番ビルドと同じでした。Firefox と Safari、スクリーンリーダーでの読み上げ、ページ内検索(Ctrl+F)で隠れた部分の言葉を探したときの動き、サーバーでの描画(SSR)は試していません。開閉のアニメーションも付けていません。

「長い文章」の欄で「続きを読む」をクリックすると、3行で切れていた文章が8行すべて見えて、ボタンが「閉じる」に変わる。「閉じる」をクリックすると3行に戻る。左上の「見えている行数」が 3行、8行、3行と変わる
「続きを読む」と「閉じる」を1回ずつ押した様子。見えている行数とボタンが変わった瞬間だけピンクにしている(カーソル、押した瞬間の輪、クリックの回数、行数の表示は GIF 用に重ねたもの)

3行で切るのは CSS に任せる

決まった行数で切って、最後に「…」を付けるのは、CSS の line-clamp の役目です。MDN によると、接頭辞の付いた -webkit-line-clamp は、display: -webkit-box と -webkit-box-orient: vertical と組み合わせたときだけ働きます。接頭辞の付いたプロパティは非推奨とされていますが、3つを組み合わせたときの動きは仕様で決められていて、今後も使えると書かれています。はみ出した部分を隠すには、overflow: hidden も要ります。

検証環境の Chromium 153 で、書き方を3通り試しました。幅 300px の箱に、文字の大きさ 16px で8行になる文章を入れ、箱の高さ(clientHeight)を比べています。3つとも overflow: hidden を付けています。

text
a: style="line-clamp: 3" clientHeight=218 scrollHeight=218
b: style="-webkit-line-clamp: 3" clientHeight=218 scrollHeight=218
c: style="display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3" clientHeight=82 scrollHeight=218

3行(82px)で切れたのは、3つを組み合わせた書き方だけでした。-webkit- の付かない line-clamp: 3 だけでは、8行(218px)のままです。

この記事のコンポーネントでは、折りたたんでいる間だけ clamped のクラスを付けます。何行で切るかは、style の WebkitLineClamp で渡します。

css
.read-more-text.clamped { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }

はみ出しているときだけ「続きを読む」を出す

短い文章にまで「続きを読む」が出ると、押しても、見える文章は増えません。そこで、折りたたんだ状態で、文章がはみ出しているかを測ります。

MDN の scrollHeight のページにあるとおり、scrollHeight は、はみ出して見えない部分も含めた中身の高さです。中身が収まっていれば clientHeight と同じになります。scrollHeight > clientHeight なら、はみ出しています。

tsx
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';

type Props = { children: ReactNode; lines?: number };

export function ReadMore({ children, lines = 3 }: Props) {
  const ref = useRef<HTMLDivElement>(null);
  const id = useId();
  const [expanded, setExpanded] = useState(false);
  const [overflowing, setOverflowing] = useState(false);

  // 折りたたんでいる間だけ、文章がはみ出しているかを測る(幅が変わったら測り直す)
  useLayoutEffect(() => {
    if (expanded) return;
    const el = ref.current!;
    const measure = () => setOverflowing(el.scrollHeight > el.clientHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [expanded, children]);

  return (
    <div className="read-more">
      <div
        ref={ref}
        id={id}
        className={expanded ? 'read-more-text' : 'read-more-text clamped'}
        style={{ WebkitLineClamp: lines }}
        // 隠れた部分のリンクなどに Tab で入ったら、広げて見えるようにする
        onFocus={() => setExpanded(true)}
      >
        {children}
      </div>
      {(overflowing || expanded) && (
        <button
          type="button"
          aria-expanded={expanded}
          aria-controls={id}
          onClick={() => setExpanded(!expanded)}
        >
          {expanded ? '閉じる' : '続きを読む'}
        </button>
      )}
    </div>
  );
}

測るのは、折りたたんでいる間だけです。広げると scrollHeight と clientHeight が同じになり、はみ出していないと判定されてしまうからです。広げている間は、測らずに「閉じる」ボタンを出し続けます。

測る処理は useLayoutEffect に書きました。公式リファレンスによると、useLayoutEffect の中の処理と、その中で更新した state は、ブラウザが画面を描く前に反映されます。

画面の幅が変わると、1行に入る文字の数が変わり、はみ出すかどうかも変わります。そこで ResizeObserver で箱の大きさの変化を見て、変わるたびに測り直します。依存配列に children を入れたのは、文章が差し替わっても箱の高さは 82px のままで、ResizeObserver では気づけないからです(文章の差し替えは、検証では試していません)。

使う側では、文章を ReadMore で包みます。検証用のページには、長い文章と短い文章を並べました。最後の「次のボタン」は、Tab キーでフォーカスがどこまで進むかを見るためのものです。

tsx
import { ReadMore } from './ReadMore';

export function App() {
  return (
    <main>
      <section data-testid="long">
        <h2>長い文章</h2>
        <ReadMore>
          <p>
            吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。この書生というのは時々我々を捕えて煮て食うという話である。
            (<a href="https://www.aozora.gr.jp/">青空文庫</a>より)
          </p>
        </ReadMore>
      </section>
      <section data-testid="short">
        <h2>短い文章</h2>
        <ReadMore>
          <p>吾輩は猫である。名前はまだ無い。</p>
        </ReadMore>
      </section>
      <button type="button">次のボタン</button>
    </main>
  );
}

画面の幅を 390px にして開いた直後の結果です。1行の高さは 27.2px(16px の 1.7 倍)なので、3行は約 82px です。長い文章は、218px(8行)ある中身が 82px で切れて「続きを読む」が出ました。1行の短い文章にはボタンが出ませんでした。

text
長い文章: clientHeight=82 scrollHeight=218 scrollTop=0 / ボタン「続きを読む」 aria-expanded=false
短い文章: clientHeight=27 scrollHeight=27 scrollTop=0 / ボタン なし

折りたたんだまま画面の幅を変えると、幅 1000px では文章がちょうど3行に収まり、ボタンが消えました。390px に戻すと、ボタンがまた出ました。ちょうど3行のときも scrollHeight と clientHeight は同じ 82 で、ボタンが余計に出ることはありませんでした。

text
幅 800px: clientHeight=82 scrollHeight=109 scrollTop=0 / ボタン「続きを読む」 aria-expanded=false
幅 900px: clientHeight=82 scrollHeight=109 scrollTop=0 / ボタン「続きを読む」 aria-expanded=false
幅 1000px: clientHeight=82 scrollHeight=82 scrollTop=0 / ボタン なし
幅 390px: clientHeight=82 scrollHeight=218 scrollTop=0 / ボタン「続きを読む」 aria-expanded=false

ボタンに aria-expanded と aria-controls を付ける

ボタンには、開いているかどうかを aria-expanded で、どの要素を開け閉めするかを aria-controls で持たせます。MDN の aria-expanded のページにある、ボタンで開け閉めするときの書き方に合わせました。aria-controls に渡す id は、useId で作っています。同じコンポーネントをページに何個置いても、id が重なりません。

ボタンを2回押した結果です。aria-expanded が true と false で切り替わりました。押したあとも、フォーカスはボタンに残りました。

text
1回目: clientHeight=218 scrollHeight=218 scrollTop=0 / ボタン「閉じる」 aria-expanded=true / フォーカス button「閉じる」
2回目: clientHeight=82 scrollHeight=218 scrollTop=0 / ボタン「続きを読む」 aria-expanded=false / フォーカス button「続きを読む」

-webkit-line-clamp で隠した部分は、画面に見えないだけで、ページからは消えていません。折りたたんだまま Playwright でアクセシビリティツリーを見ると、文章は最後まで入っていて、リンクもありました。

text
- heading "長い文章" [level=2]
- paragraph:
  - text: 吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。この書生というのは時々我々を捕えて煮て食うという話である。 (
  - link "青空文庫":
    - /url: https://www.aozora.gr.jp/
  - text: より)
- button "続きを読む"

実際のスクリーンリーダーでどう読み上げられるかは試していません。

隠れた部分のリンクに Tab で入ったら広げる

アクセシビリティツリーに残るのと同じように、隠れた部分のリンクも、Tab キーでフォーカスできます。onFocus で広げる処理を外した版で、折りたたんだまま Tab を押した結果です。

text
Tab 1回目: フォーカス a「青空文庫」 / clientHeight=82 scrollHeight=218 scrollTop=136 / ボタン「続きを読む」 aria-expanded=false
Tab 2回目: フォーカス button「続きを読む」 / clientHeight=82 scrollHeight=218 scrollTop=136 / ボタン「続きを読む」 aria-expanded=false
Tab 3回目: フォーカス button「次のボタン」 / clientHeight=82 scrollHeight=218 scrollTop=136 / ボタン「続きを読む」 aria-expanded=false

最初の Tab で、8行目にある「青空文庫」のリンクにフォーカスが移りました。このとき、リンクが見える位置まで、箱の中身が 136px(5行分)スクロールしました。箱の高さは 82px のままなので、見えるのは6〜8行目だけです。文章の頭が隠れ、「…」も消えました。Tab で次へ進んでも、スクロールは元に戻りませんでした。

上は開いた直後の「長い文章」の欄で、1〜3行目と「…」が見えている。下は onFocus の処理がない版で Tab を1回押したあとで、6〜8行目が見え、「青空文庫」のリンクに枠が付いている
幅 390px の画面で撮った「長い文章」の欄。下は、隠れていたリンクにフォーカスが移り、箱の中身がスクロールした状態

そこで、文章の箱に onFocus={() => setExpanded(true)} を付けました。公式リファレンスにある、フォーカスのイベントを扱う例にあるとおり、React の onFocus はブラウザの focus イベントと違ってバブリングします。そのため、箱の中のリンクにフォーカスが移ったときにも呼ばれます。

この書き方は、フォーカスが移った先が隠れた部分かどうかを区別しません。見えている1〜3行目にリンクがある場合も広がるはずですが、そのような文章では試していません。隠れた部分のときだけ広げたいなら、フォーカスした要素の位置と箱の位置を比べる処理が要ります。

付けた版で同じ操作をした結果です。最初の Tab で文章が広がってボタンが「閉じる」になり、箱の中身はスクロールしませんでした(scrollTop=0)。リンクは8行目にそのまま見えています。

text
Tab 1回目: フォーカス a「青空文庫」 / clientHeight=218 scrollHeight=218 scrollTop=0 / ボタン「閉じる」 aria-expanded=true
Tab 2回目: フォーカス button「閉じる」 / clientHeight=218 scrollHeight=218 scrollTop=0 / ボタン「閉じる」 aria-expanded=true
Tab 3回目: フォーカス button「次のボタン」 / clientHeight=218 scrollHeight=218 scrollTop=0 / ボタン「閉じる」 aria-expanded=true

2回目の Tab では、「閉じる」に変わったボタンにフォーカスが移りました。リンクの次にボタンがあるので、読み終えたらそこで折りたたみ直せます。

​Posted by 86