Reactで、枠に収まるように文字の大きさを調整する方法
見出しを枠いっぱいに表示したいときは、font-size を変えながら、文字が枠からはみ出していないかを測ります。はみ出しているかどうかは、要素の scrollWidth と clientWidth を比べれば分かります。この測定を useLayoutEffect の中で行い、枠の大きさが変わったときと、Web フォントの読み込みが終わったときにやり直せば、文章の長さにも枠の幅にも合わせられます。
検証環境は 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 を操作して、文字の大きさと幅を読み取りました。日本語のフォントは Noto Sans CJK JP です。Firefox と Safari、スマートフォンの実機、サーバーでレンダリングする構成(Next.js など)では試していません。

CSS の単位だけでは、文章の長さに合わせられない
文字の大きさを枠の幅に合わせるだけなら、CSS のコンテナクエリの単位でもできます。MDN のコンテナークエリーのガイドによると、cqi はクエリコンテナのインライン方向(横書きなら横)の大きさの 1% です。
ただし、cqi で決まるのは「枠の幅に対する割合」だけです。文字数が多くても少なくても、同じ大きさになります。幅 320px のコンテナで font-size: 12cqi にしたところ、3つの見出しはどれも 38.4px でした。
「猫の手」 38.4px 幅 320/320 高さ 56/56 横に収まる=true 縦に収まる=true
「それなら猫の手で」 38.4px 幅 320/320 高さ 56/56 横に収まる=true 縦に収まる=true
「文字の大きさを枠に合わせて変える」 38.4px 幅 613/320 高さ 56/56 横に収まる=false 縦に収まる=true
「幅」は scrollWidth/clientWidth です。16文字の見出しは中身の幅が 613px になり、320px の枠からはみ出しました。逆に、3文字の見出しは枠に余白が残ったままです。CSS の単位だけでは文章の長さに合わせられないので、この記事では、実際に描画した幅を測って大きさを決めます。

画像は、枠の幅を 360px(中身の幅は 340px)にして撮りました。12cqi の3つは、どれも 40.8px です。FitText(後で作るコンポーネント)は max={64} にして撮りました。64px(最大の値)、42px、21px と、文字数に合わせて変わりました。
はみ出さない大きさを二分探索で探す
まず、要素と、文字の大きさの範囲を受け取って、収まるいちばん大きい値を探す関数を作ります。React には依存しない、ふつうの関数です。
// 要素の中身が、はみ出していないかを調べる
function fits(el: HTMLElement, multiline: boolean): boolean {
if (el.scrollWidth > el.clientWidth) return false;
// 高さを見るのは、高さを決めた複数行の枠のときだけ
return !multiline || el.scrollHeight <= el.clientHeight;
}
// min〜max の範囲で、収まるいちばん大きい文字の大きさ(px)を二分探索で探す
export function fitFontSize(el: HTMLElement, min: number, max: number, multiline = false): number {
let lo = min;
let hi = max;
let best = min;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
el.style.fontSize = `${mid}px`;
if (fits(el, multiline)) {
best = mid; // 収まったので、もっと大きくできるか試す
lo = mid + 1;
} else {
hi = mid - 1; // はみ出したので、小さくする
}
}
el.style.fontSize = `${best}px`;
return best;
}
MDN の scrollWidth の説明によると、scrollWidth は、はみ出して見えない部分も含めた中身の幅です。中身が枠に収まっていれば、clientWidth と同じ値になります。そこで、scrollWidth が clientWidth より大きければ「はみ出している」と判断します。
範囲の真ん中の大きさを試し、収まれば上の半分、はみ出せば下の半分に範囲を狭めます。1px ずつ試すより、測る回数がずっと少なくて済みます。12〜96px の 85 通りなら、多くても7回です。文字が大きいほど幅も広がるので、この探し方が使えます。
幅 320px の枠で、1行の見出しを min 12、max 96 で調べた結果です。
「猫」 96px 幅 320/320 高さ 127/115 横に収まる=true 縦に収まる=false
「猫の手」 96px 幅 320/320 高さ 127/115 横に収まる=true 縦に収まる=false
「それなら猫の手で」 40px 幅 320/320 高さ 53/48 横に収まる=true 縦に収まる=false
「文字の大きさを枠に合わせて変える」 20px 幅 320/320 高さ 26/24 横に収まる=true 縦に収まる=false
8文字の見出しは 40px、16文字は 20px で、どちらも横に収まりました。1文字と3文字の見出しは、上限の 96px で止まっています。決まった大きさから 1px だけ大きくすると、8文字と16文字の見出しは、どちらもはみ出しました。収まるいちばん大きい値が選ばれています。
1行の見出しで高さまで比べると、文字が最小の大きさになる
上の結果では、「縦に収まる」がどれも false になっています。比べるために、幅と高さの両方を見る判定で1行の見出しを調べると、どの見出しも最小の 12px になりました。
["短い→12px","ちょうどいい長さの見出し→12px","長い見出しほど、文字は小さくなります→12px","あ→12px"]
1行の見出しは、高さを決めず、中身に合わせて伸ばしています。それでも scrollHeight が clientHeight より大きくなっていました。40px の文字で line-height を変えて測ると、次のようになります。
[{"lineHeight":"1.2","clientHeight":48,"scrollHeight":53},{"lineHeight":"normal","clientHeight":58,"scrollHeight":58},{"lineHeight":"1.5","clientHeight":60,"scrollHeight":60}]
line-height: 1.2 のときだけ、要素の高さ 48px に対して scrollHeight が 53px でした。line-height: normal のときの高さは 58px なので、このフォントの文字は、本来 48px の行より 10px ほど高い場所を取ります。この 10px が上下に 5px ずつはみ出し、そのうち下の 5px が scrollHeight に数えられた、と考えると数値が合います(仕組みは確かめていません)。どの大きさでも同じことが起きるので、二分探索は最後まで「はみ出した」と判断し続けます。
そこで、1行の見出しでは幅だけを比べ、高さは、高さを決めた複数行の枠のときだけ比べるようにしました。それが上の multiline の引数です。
測り直すタイミングをまとめたコンポーネント
fitFontSize を、React のコンポーネントから呼びます。
import { useLayoutEffect, useRef } from 'react';
import { fitFontSize } from './fitFontSize';
type Props = {
children: string;
min?: number; // これより小さくしない(px)
max?: number; // これより大きくしない(px)
multiline?: boolean; // 高さを決めた枠の中で、折り返して表示する
className?: string;
onFit?: (size: number) => void;
};
export function FitText({ children, min = 12, max = 96, multiline = false, className = '', onFit }: Props) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const run = () => {
const size = fitFontSize(el, min, max, multiline);
onFit?.(size);
};
run();
// 枠の大きさが変わったら、次のフレームで測り直す
let frame = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(run);
});
observer.observe(el);
// Web フォントの読み込みが終わったら、測り直す
document.fonts.addEventListener('loadingdone', run);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
document.fonts.removeEventListener('loadingdone', run);
};
}, [children, min, max, multiline, onFit]);
return (
<div ref={ref} className={`fit-text ${multiline ? 'multiline' : 'one-line'} ${className}`}>
{children}
</div>
);
}
CSS は次のとおりです。overflow: hidden を付けておくと、はみ出した中身の幅が scrollWidth に出ます。1行の見出しは white-space: nowrap で折り返さないようにし、複数行の枠には高さを決めておきます。
.fit-text {
overflow: hidden;
line-height: 1.2;
}
.fit-text.one-line {
white-space: nowrap;
}
.card {
height: 120px; /* 複数行のときは、枠の高さを決めておく */
}
測定を useEffect ではなく useLayoutEffect で行うのは、描画の前に大きさを決めたいからです。公式リファレンスによると、useLayoutEffect の中身は、ブラウザが画面を描き直す前に実行されます。リファレンスには、描画の前にレイアウトを測る使い方が、例として載っています。
文字の大きさは state に持たず、el.style.fontSize に直接書いています。二分探索の途中で何度も大きさを変えて測るので、そのたびにレンダーし直すより手早く済みます。決まった大きさを画面に出したいときは、onFit で受け取ります。onFit は依存配列に入っているので、レンダーのたびに作り直す関数を渡すと、そのたびに測り直しになります。useState の set 関数のように、変わらない関数を渡します。
文章が変わったとき、枠の幅が変わったとき
children は依存配列に入っているので、文章を変えると effect がやり直され、新しい文章で測り直します。幅 320px のまま文章を変えた結果です。
「短い」 96px 幅 320/320 高さ 127/115 横に収まる=true 縦に収まる=false
「ちょうどいい長さの見出し」 26px 幅 320/320 高さ 34/31 横に収まる=true 縦に収まる=false
「長い見出しほど、文字は小さくなります」 17px 幅 320/320 高さ 22/20 横に収まる=true 縦に収まる=false
「あ」 96px 幅 320/320 高さ 127/115 横に収まる=true 縦に収まる=false
onFit で受け取った大きさ {"l0":96,"l1":26,"l2":17,"l3":96}
枠の幅の変化は、ResizeObserver で受け取ります。要素の大きさが変わったことを知らせてくれる仕組みです。最初の4つの見出しを入れた枠を、320px から 200px、480px に変えると、次のようになりました。
| 見出し | 320px | 200px | 480px |
|---|---|---|---|
| 猫 | 96px | 96px | 96px |
| 猫の手 | 96px | 66px | 96px |
| それなら猫の手で | 40px | 25px | 60px |
| 文字の大きさを枠に合わせて変える | 20px | 12px | 30px |
枠が狭くなると小さく、広くなると大きくなりました。200px の枠では、16文字の見出しがちょうど最小の 12px になりました(12px で収まっています)。
ResizeObserver のコールバックで、すぐに測り直さない
上のコードでは、ResizeObserver のコールバックで fitFontSize をすぐには呼ばず、requestAnimationFrame で次のフレームに回しています。すぐに呼ぶと、window にエラーが出るからです。
1行の見出しは高さを決めていないので、文字の大きさを変えると、同じ要素の高さも変わります。MDN の ResizeObserver の説明によると、コールバックの中で、同じ要素(または DOM のより浅い位置にある要素)の大きさが変わると、その通知は次の描画に回されます。そのとき window に「ResizeObserver loop completed with undelivered notifications.」というエラーイベントが出ます。
コールバックの中ですぐに測り直す版と、requestAnimationFrame に回す版(上の FitText)で、枠の幅を 320px → 200px → 480px → 320px と変え、window の error イベントを数えました。
--- ResizeObserver の扱い:コールバックの中ですぐに測り直す版
幅 320px:測った回数(累計)4、文字の大きさ 40px、window の error(累計)0
幅 200px:測った回数(累計)6、文字の大きさ 25px、window の error(累計)1
幅 480px:測った回数(累計)8、文字の大きさ 60px、window の error(累計)2
幅 320px:測った回数(累計)10、文字の大きさ 40px、window の error(累計)3
--- ResizeObserver の扱い:requestAnimationFrame で次のフレームに回す版(FitText)
幅 320px:測った回数(累計)3、文字の大きさ 40px、window の error(累計)0
幅 200px:測った回数(累計)5、文字の大きさ 25px、window の error(累計)0
幅 480px:測った回数(累計)7、文字の大きさ 60px、window の error(累計)0
幅 320px:測った回数(累計)9、文字の大きさ 40px、window の error(累計)0
最初の行の回数には、開発時の StrictMode で effect が一度余分に実行された分も入っています。比べるのは、幅を変えたときに増えた回数です。
すぐに測り直す版では、幅を変えるたびにエラーが1回ずつ出ました。requestAnimationFrame に回す版では、エラーは出ませんでした。どちらも、幅を1回変えるごとに2回測り、同じ大きさに決まったところで止まっています。決まった文字の大きさも同じです。なお、このエラーは Playwright の pageerror や console には出ず、window の error イベントでだけ受け取れました。
requestAnimationFrame に回すと、枠の幅が変わってから、新しい大きさになるまでに1フレーム遅れます。その間に古い大きさのまま描画されることがあるはずですが、画面のちらつきとして見えるかどうかは確かめていません。
複数行の枠に収める
説明文のように折り返して表示したいときは、枠の高さを決め、multiline を付けます。幅と高さの両方を比べるので、行が増えて下にはみ出す大きさは選ばれません。
<FitText multiline className="card">{text}</FitText>
幅 320px、高さ 120px の枠で試した結果です。
「枠に収まるように、文字の大きさを自動で調整します。」 32px 幅 320/320 高さ 120/120 横に収まる=true 縦に収まる=true
「文章が長くなると、行の数が増え、そのぶん文字は小さくなります。複数行でも、幅と高さの両方を見て決めます。」 24px 幅 320/320 高さ 120/120 横に収まる=true 縦に収まる=true
25文字の文章は 32px、52文字の文章は 24px になり、どちらも枠の高さに収まりました。
Web フォントの読み込みを待つ
Web フォントを使うと、最初は代わりのフォントで描画され、読み込みが終わってからフォントが切り替わることがあります。フォントが変わると文字の幅も変わりますが、枠の大きさは変わらないので、ResizeObserver は呼ばれません。
そこで、document.fonts の loadingdone イベントを受け取って、測り直しています。MDN によると、文書がフォントを読み込み終えたときに発生するイベントです。
効き目を確かめるため、幅の広い Web フォントを font-display: swap で読み込み、届くまでの時間を 800ms 遅らせました。loadingdone を受け取る FitText と、受け取らない版を並べています。
読み込み前 fonts.status = loading
「Fit text sample」 49px 幅 320/320 高さ 59/59 横に収まる=true 縦に収まる=true
「Fit text sample」 49px 幅 320/320 高さ 59/59 横に収まる=true 縦に収まる=true
読み込み後 fonts.status = loaded
上:loadingdone を聞く版、下:聞かない版
「Fit text sample」 35px 幅 320/320 高さ 42/42 横に収まる=true 縦に収まる=true
「Fit text sample」 49px 幅 443/320 高さ 59/59 横に収まる=false 縦に収まる=true
読み込む前は、どちらも代わりのフォントで 49px に決まりました。読み込んだ後、loadingdone を受け取る版は 35px に測り直して枠に収まりました。受け取らない版は 49px のままで、中身の幅が 443px になり、枠からはみ出しました。
最小の大きさでも収まらないとき
min より小さくはしないので、とても長い文章は収まりません。min を 16 にして、30文字の見出しを幅 320px の枠に入れた結果です。
「とても長い見出しは、いちばん小さい大きさでも枠に収まりません」 16px 幅 478/320 高さ 21/19 横に収まる=false 縦に収まる=false
16px で止まり、中身の幅は 478px でした。overflow: hidden なので、枠に入らない後ろの部分は切れて見えなくなります。小さくしすぎると読めなくなるので、min は読める大きさにしておき、それでも収まらないほど長い文章は、文章のほうを短くするのが確実です。
fitFontSize は、1つの要素につき何回も大きさを変えて測ります。この検証環境で、1行の見出し100個に fitFontSize を続けて実行したところ、76.6ms かかりました。たくさんの要素に使うときは、この時間を見込んでおく必要があります。





