Reactで、タブのタイトルとアイコンに未読の件数を出す方法
未読が3件あるとき、ブラウザのタブに「(3) 猫のメモ」と出し、ファビコンの右上にピンクの丸と「3」を描きます。ほかのタブを開いていても、タブの並びを見れば新着があることが分かる、という狙いです。
タイトルは React 19 の <title> に件数を混ぜた文字列を渡すだけで変わります。ファビコンは、元の画像を canvas に描き、その上に件数を重ねた PNG を作って、<link rel="icon"> の href に入れます。
検証環境は 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 で変換し、Node.js で作った検証用のサーバーから配信しました。タブの表示を撮るため、Chromium は Xvfb の仮想画面の上で、ヘッドレスでないウィンドウとして動かしています。Firefox と Safari、スマートフォンのブラウザ、サーバーレンダリングは試していません。タブを裏に回したときの動きも確かめられていません(Playwright で開いたページは、別のタブを前に出しても document.visibilityState が visible のままでした)。

GIF は、表示倍率を1.5倍にしたウィンドウを撮ったものです。上の段は、同じ画面のタブの部分を切り出して2倍にしたものです。右上に出したクリックの回数、カーソルと押した位置の輪は、撮影のために重ねました。ページの中の件数は、変わった瞬間だけピンクにしています。
タイトルの先頭に件数を付ける
件数は、あるときだけ (3) 猫のメモ のようにタイトルの先頭に付けます。100件以上は 99+ にまとめます。
export function formatCount(count: number) {
return count > 99 ? '99+' : String(count);
}
export function UnreadTitle({ count, title }: { count: number; title: string }) {
// <title> の中身は文字列1つにする
const text = count > 0 ? `(${formatCount(count)}) ${title}` : title;
return <title>{text}</title>;
}
React 19 では、コンポーネントの中に書いた <title> を、React が <head> へ移します。<title> の公式リファレンスにあるとおり、<title> の中身は文字列1つでなければなりません。<title>({count}) 猫のメモ</title> のように JSX の {} で混ぜると、中身が配列になってしまいます。そのため、テンプレートリテラルで1つの文字列にしてから渡しています。この落とし穴は、以前の記事「React 19で、titleやmetaをコンポーネントの中に書く方法」でも試しました。
検証環境で件数を変えたときの document.title は、次のとおりです。
| 未読の件数 | document.title |
|---|---|
| 0 | 猫のメモ |
| 1 | (1) 猫のメモ |
| 10 | (10) 猫のメモ |
| 99 | (99) 猫のメモ |
| 100 | (99+) 猫のメモ |
検証用の index.html には、最初から <title>猫のメモ</title> を書いてあります。React の <title> を足すと、<head> の <title> は2つになりましたが、document.title は件数の付いたほうになりました。ただし、公式リファレンスには、<title> が複数あるときのブラウザの動きは決まっていない、という注意があります。2つ並んだときの動きは、以前の記事で詳しく確かめています。コンポーネントをアンマウントすると、document.title は index.html の「猫のメモ」に戻りました。
ファビコンに件数を描く
ファビコンは、元の画像(32×32px の PNG)を canvas に描き、右上にピンクの角丸の四角と白い数字を重ねます。数字が2文字以上のときは、文字を小さくし、四角を横に伸ばします。
import { formatCount } from './UnreadTitle';
const SIZE = 32;
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous'; // 別オリジンの画像は CORS で読み込む
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`画像を読み込めません: ${src}`));
img.src = src;
});
}
export function drawBadgeIcon(base: HTMLImageElement, count: number): string {
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas に描けません');
ctx.drawImage(base, 0, 0, SIZE, SIZE);
const label = formatCount(count);
ctx.font = `bold ${label.length === 1 ? 20 : 15}px sans-serif`;
const width = Math.max(20, ctx.measureText(label).width + 6);
const height = 20;
const x = SIZE - width;
const y = 0;
// 右上に、数字の幅に合わせた角丸の四角を描く
ctx.fillStyle = '#d85878';
ctx.beginPath();
ctx.roundRect(x, y, width, height, height / 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, x + width / 2, y + height / 2 + 1);
return canvas.toDataURL('image/png');
}
角丸の四角は roundRect() で描いています。四角の高さは 20px で、角の半径をその半分にしているので、1文字のときは丸、2文字以上のときは横長の角丸になります。最後に toDataURL() で、描いた内容を data:image/png;base64,… の形の文字列にします。この文字列は、そのまま href に入れられます。
検証環境で作られたファビコンを、4倍に拡大して並べると次のようになりました。

上の画像は4倍に拡大したものです。タブの中のファビコンは、これよりずっと小さく表示されます。冒頭の GIF の下の段は、表示倍率1.5倍の画面をそのままの大きさで撮ったもので、上の段はそれを2倍にしたものです。この画面では、「3」のような1文字の数字は読み取れました。表示倍率1倍の画面や、2文字以上・「99+」のタブの中での見え方は確かめていません。
link の href を書き換えるフック
作った画像は、index.html に最初からある <link rel="icon"> の href を書き換えて表示します。
import { useEffect } from 'react';
import { drawBadgeIcon, loadImage } from './drawBadgeIcon';
function getIconLink() {
return document.querySelector<HTMLLinkElement>('link[rel="icon"]');
}
export function useFaviconBadge(count: number, baseHref: string) {
useEffect(() => {
const link = getIconLink();
if (!link) return;
if (count <= 0) {
link.href = baseHref;
return;
}
let ignore = false;
loadImage(baseHref)
.then((img) => {
if (!ignore) link.href = drawBadgeIcon(img, count);
})
.catch((error) => {
console.warn('ファビコンにバッジを描けません', error);
});
return () => {
ignore = true; // 古い件数の描画が、あとから上書きしないようにする
};
}, [count, baseHref]);
// 画面から外れたら、元のアイコンに戻す
useEffect(() => {
return () => {
const link = getIconLink();
if (link) link.href = baseHref;
};
}, [baseHref]);
}
新しい <link rel="icon"> を足さずに、いまある1つを書き換えているのには理由があります。MDN の rel 属性の説明によると、<link rel="icon"> が複数あるときは、ブラウザが media、type、sizes の属性を見て、いちばん合うものを選びます。足したほうが使われるとは限りません。検証では、件数を変えても <link rel="icon"> は1つのままでした。なお、このフックが書き換えるのは最初に見つかった1つだけです。MDN の説明から考えると、大きさの違うアイコンを sizes で何枚も並べているページでは、ブラウザが書き換えていないほうを選んで、件数が出ないことがありえます(この場合は試していません)。
画像の読み込みは非同期なので、読み込んでいるあいだに件数が変わることがあります。そこで、公式リファレンスの、エフェクトでデータを取ってくる例と同じように、クリーンアップ関数で ignore を true にします。古い件数の画像の読み込みがあとから終わっても、描かずに終わり、href は書き換えません。
検証では、ボタンを3回続けて押しました。href が書き換わったのは1回だけで、そのときの画像は、1件ずつ押して3件にしたときと同じでした。1件目と2件目の読み込みの結果は使われていません。
2つ目の useEffect は、コンポーネントが画面から外れたときに、元のアイコンに戻すためのものです。件数が2件のときにアンマウントすると、href は /favicon.png に戻りました。開発用のビルドで StrictMode を付けて動かしても、3件のときの画像と、すべて既読にしたあとの href は、本番用のビルドと同じでした。
未読の件数を持つコンポーネントで使う
使う側では、未読の件数を UnreadTitle と useFaviconBadge の両方に渡します。検証では、ボタンで件数を増やしたり0に戻したりしました。
import { useState } from 'react';
import { UnreadTitle } from './UnreadTitle';
import { useFaviconBadge } from './useFaviconBadge';
export function Inbox({ iconHref = '/favicon.png' }: { iconHref?: string }) {
const [unread, setUnread] = useState(0);
useFaviconBadge(unread, iconHref);
return (
<main>
<UnreadTitle count={unread} title="猫のメモ" />
<h1>猫のメモ</h1>
<p className="unread">
未読 <output data-testid="unread">{unread}</output> 件
</p>
<button onClick={() => setUnread((n) => n + 1)}>新着を1件受け取る</button>
<button onClick={() => setUnread(0)}>すべて既読にする</button>
</main>
);
}
index.html の <head> には、元になる <title> と <link rel="icon"> を書いておきます。
<title>猫のメモ</title>
<link rel="icon" type="image/png" href="/favicon.png">
元のアイコンを別のオリジンに置くとき
元のアイコンを CDN など別のオリジンに置いていると、件数を描けないことがあります。MDN の、別オリジンの画像を canvas で使う説明によると、CORS の許可なしに読み込んだ別オリジンの画像を canvas に描くと、その canvas は「汚染された」状態になり、toDataURL() を呼ぶとエラーになります。
検証では、アプリを 127.0.0.1:8100 から配信し、アイコンだけを 127.0.0.1:8101 から返しました。ポート番号が違うので、別のオリジンです。
| 読み込み方 | アイコンのサーバーの応答 | 結果 |
|---|---|---|
crossOrigin なし |
Access-Control-Allow-Origin: * あり |
toDataURL() が SecurityError |
crossOrigin = 'anonymous' |
CORS のヘッダーなし | 画像の読み込みが失敗し、アイコンは元のまま |
crossOrigin = 'anonymous' |
Access-Control-Allow-Origin: * あり |
件数を描けた |
表の1行目のとおり、サーバーが CORS のヘッダーを返していても、<img> の側で crossOrigin を付けなければ、canvas は汚染されます。記事の loadImage が crossOrigin = 'anonymous' を付けているのは、このためです。
表の2行目では、ブラウザのコンソールに CORS でブロックされたというエラーが出て、loadImage の Promise が失敗しました。useFaviconBadge は警告を出すだけで、href は元のアイコンのままです。タイトルの件数は、この場合も「(1) 猫のメモ」と出ました。
元のアイコンは、アプリと同じオリジンに置くのがいちばん簡単です。別のオリジンに置くなら、Access-Control-Allow-Origin を返すようにサーバーを設定し、画像を crossOrigin = 'anonymous' で読み込みます。





