Reactで、文字の大きさを切り替えるボタンを作る方法

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

「小・中・大」のボタンでページ全体の文字の大きさを切り替えるには、React では <html> に data-font-size="large" のような属性を付けるだけにして、大きさは CSS で決めます。CSS では <html> の font-size を % で変え、見出しや本文の文字は rem で指定しておくと、ページの文字がまとめて同じ割合で大きくなります。

この記事のコードは、本番ビルドを Chromium で開き、ボタンのクリックとキーボード操作、再読み込み、ブラウザの既定の文字サイズを変えたときの大きさを確かめたものです。

補足

検証環境は 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 で操作しました。StrictMode を付けた開発ビルドでも同じページを開いてボタンを押し、React の警告が出ないことを確かめています。既定の文字サイズは Chrome DevTools Protocol で変えており、ブラウザの設定画面からは変えていません。Firefox と Safari、スマートフォンのブラウザ、ブラウザの拡大・縮小(ズーム)との組み合わせ、スクリーンリーダーでの読み上げ、Next.js などのサーバーで描画するフレームワークの上での動きは試していません。

「大」を押すと、見出し・本文・ボタンの文字が大きくなり、左下の html の font-size が 16px から 20px になる。「小」を押すと 14px、「中」を押すと 16px に戻る。押されているボタンは塗りつぶしになる。「px で指定した文字」の行だけは、どのボタンを押しても大きさが変わらない
「大」「小」「中」の順にボタンを押したときの表示の変化。カーソル、押した場所の輪、右下の回数、左下の font-size の表示は GIF 用に重ねたもの。font-size は、変わった瞬間だけピンクになる

html の font-size を % で変え、ほかの文字は rem で指定する

CSS は次のとおりです。「中」のときは <html> の font-size を指定せず、ブラウザの既定の大きさのままにします。「小」は 87.5%、「大」は 125% です。

css
html[data-font-size='small'] { font-size: 87.5%; }
html[data-font-size='large'] { font-size: 125%; }

body { margin: 0; font-family: 'IPAGothic', sans-serif; font-size: 1rem; line-height: 1.6; color: #1f2a30; }
.site-header { display: flex; justify-content: space-between; align-items: center;
  padding: 0.5rem 1rem; background: #eefafd; font-weight: bold; }
main { padding: 0 1rem; }
h1 { font-size: 1.5rem; margin: 0.75rem 0 0.25rem; }
p { margin: 0.5rem 0; }
.fixed { font-size: 14px; color: #6b767c; }

.font-size-switcher { display: flex; gap: 0.25rem; }
.font-size-switcher button { min-width: 2.75rem; padding: 0.25rem 0.5rem; font: inherit; font-size: 0.875rem;
  border: 1px solid #067a98; border-radius: 4px; background: #fff; color: #067a98; cursor: pointer; }
.font-size-switcher button[aria-pressed='true'] { background: #067a98; color: #fff; }

MDN の font-size のページによると、rem で指定した大きさは <html> 要素のフォントサイズからの相対値です。% は親要素のフォントサイズからの相対値です。<html> に付けた % は、ブラウザの既定の文字サイズを基準にした大きさになりました(後の節で、既定の文字サイズを変えて確かめています)。

見出し、本文、ボタンの文字を rem で書いておくと、<html> の値を1か所変えるだけで、すべてが同じ割合で変わります。比べるために、1行だけ font-size: 14px にした文字(.fixed)を置きました。

検証環境の Chromium(既定の文字サイズは 16px)で、各ボタンを押したときの計算後の font-size です。

ボタン html 見出し(1.5rem) 本文(1rem) ボタン(0.875rem) px で指定した文字
中 16px 24px 16px 14px 14px
大 20px 30px 20px 17.5px 14px
小 14px 21px 14px 12.25px 14px

rem で書いた文字は、<html> と同じ割合で変わりました。px で書いた文字だけは、どのボタンでも 14px のままです。すでにあるサイトにこのボタンを付けるときは、文字の大きさを px で書いている箇所を rem に直しておかないと、その部分だけ大きさが変わりません。

選んだ大きさを state に持ち、ボタンは aria-pressed で示す

まず、大きさの選択肢と、localStorage に読み書きする関数です。

ts
export const SIZES = [
  { id: 'small', label: '小' },
  { id: 'medium', label: '中' },
  { id: 'large', label: '大' },
] as const;

export type FontSizeId = (typeof SIZES)[number]['id'];

export const DEFAULT_SIZE: FontSizeId = 'medium';
const STORAGE_KEY = 'font-size';

function isFontSizeId(v: string | null): v is FontSizeId {
  return SIZES.some((s) => s.id === v);
}

// 保存した値が3つのどれかのときだけ使う。それ以外は「中」にする
export function readStoredSize(): FontSizeId {
  try {
    const v = localStorage.getItem(STORAGE_KEY);
    if (isFontSizeId(v)) return v;
  } catch {
    // localStorage が使えない設定のブラウザでは、既定の大きさにする
  }
  return DEFAULT_SIZE;
}

export function saveSize(id: FontSizeId) {
  try {
    localStorage.setItem(STORAGE_KEY, id);
  } catch {
    // 保存できなくても、開いているあいだは選んだ大きさを使う
  }
}

localStorage から読んだ値は、'small'・'medium'・'large' のどれかのときだけ使います。検証環境で localStorage に 'huge' を入れてから開くと、「中」が押された状態になり、<html> の font-size は 16px でした。

ボタンのコンポーネントです。

tsx
import { useEffect, useState } from 'react';
import { readStoredSize, saveSize, SIZES, type FontSizeId } from './fontSize';

export function FontSizeSwitcher() {
  const [size, setSize] = useState<FontSizeId>(readStoredSize);

  useEffect(() => {
    // <html data-font-size="large"> のようにして、大きさは CSS に任せる
    document.documentElement.dataset.fontSize = size;
    saveSize(size);
  }, [size]);

  return (
    <div role="group" aria-label="文字の大きさ" className="font-size-switcher">
      {SIZES.map((s) => (
        <button
          key={s.id}
          type="button"
          aria-pressed={size === s.id}
          onClick={() => setSize(s.id)}
        >
          {s.label}
        </button>
      ))}
    </div>
  );
}

useState には、readStoredSize() の結果ではなく関数そのものを渡しています。React の useState のリファレンスにあるとおり、関数を渡すと、React はそれを初期化のときだけ呼びます。こうすると、再レンダーのたびに localStorage を読むことはありません。

<html> の属性は dataset で付けます。dataset.fontSize に入れた値は、data-font-size 属性になります。CSS は、この属性の値で font-size を切り替えます。

どのボタンが選ばれているかは、aria-pressed で示します。MDN によると、aria-pressed はトグルボタンの「押されている」状態を示す属性です。見た目も、CSS の button[aria-pressed='true'] で塗りつぶしにしているので、aria-pressed の値と画面の表示がずれません。3つのボタンは、関連する部品の集まりを表す group ロールの要素(role="group")で囲み、aria-label で「文字の大きさ」という名前を付けました。

検証環境で開くと、Playwright の getByRole('group', { name: '文字の大きさ' }) で囲みが見つかりました。getByRole('button', { pressed: true }) で見つかったのは「中」のボタンです。「大」「小」「中」の順にクリックすると、そのたびに押したボタンだけが aria-pressed="true" になり、data-font-size と localStorage の値も large、small、medium と変わりました。

ボタンは <button> なので、キーボードでも押せます。ページを開いて Tab を3回押すと「大」にフォーカスが移り、Enter で <html> の font-size が 20px になりました。続けて Shift+Tab を2回押して「小」に戻り、Space を押すと 14px になりました。

再読み込みしたときは、React より先に head のスクリプトで戻す

<html> の属性は、React のコードが届いて動くまで付きません。そのため、保存した大きさを React だけで戻そうとすると、JavaScript の読み込みを待っている間は「中」の大きさで表示されます。そこで、index.html の <head> に小さなスクリプトを置き、React より先に属性を付けます。

html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文字の大きさ</title>
<script>
  // React より先に、保存した大きさを <html> に付けておく
  try {
    var s = localStorage.getItem('font-size');
    if (s === 'small' || s === 'medium' || s === 'large') document.documentElement.dataset.fontSize = s;
  } catch (e) {}
</script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="root"></div>
<script src="main.js"></script>
</body>
</html>

検証環境で「大」を押してから、React のコード(main.js)の読み込みを1秒遅らせて再読み込みし、React がまだ何も描いていない間の大きさを調べました。

<head> のスクリプト React が動く前の html React が動いた後の html
あり 20px 20px
なし 16px 20px

スクリプトがないと、React が動くまでの間は 16px で、動いた後に 20px へ変わりました。スクリプトがあれば、最初から 20px です。どちらの場合も、React が動いた後は「大」のボタンが押された状態になっていました。

px で決めると、ブラウザの既定の文字サイズが効かなくなる

大きさを % ではなく px で決めることもできます。たとえば、html { font-size: 16px; } を基本にして「小」を 14px、「大」を 20px にする書き方です。ブラウザの既定の文字サイズが 16px なら、どちらの書き方でも同じ大きさになります。

違いが出るのは、ブラウザの既定の文字サイズが 16px ではないときです。検証環境では、Chrome DevTools Protocol の Page.setFontSizes で、ブラウザの既定の文字サイズを 20px にして比べました。

ボタン % で指定した CSS の html px で指定した CSS の html
中 20px 16px
大 25px 20px
小 17.5px 14px

% で書いた CSS は、既定の 20px を基準に大きさが変わりました。px で書いた CSS は、既定の文字サイズにかかわらず 16px・20px・14px のままです。「中」を選んでいるのに、既定の文字サイズ(20px)より小さく表示されます。

MDN の font-size のページにも、px でフォントサイズを決めるとブラウザによっては利用者がフォントサイズを変えられなくなる、と書かれています。同じページには、アクセシビリティのためには、利用者の既定のフォントサイズからの相対値を使うのが一般的に最適だともあります。この記事のボタンも、% で指定しているので、ブラウザの既定の文字サイズを基準に一段大きく・小さくなります。

​Posted by 86