ページが見つからないとき(404)と、表示の途中でエラーが起きたときの画面を作り直しました。どちらにも、マスコットの Friko が、輪郭だけで足元から薄れていく姿で出ます。404 では迷子の Friko、エラーでは泣き顔の Friko です。

これまでの 404 は、アプリを今のデザインに切り替えたあとも、古いデザインの色のままでした。表示の途中でエラーが起きたときの画面はなく、Next.js が用意している英語の短い文だけの画面が出ていました。
輪郭だけで描く
Friko は、頭に振り子を載せてヘッドホンを付けた、ドット絵のキャラクターです。コードの中では、1 文字を 1 ドットとする文字の並びで絵を持っています。体のドットはアクセントの色(ユーザーが選べるテーマの色)で塗り、目と、振り子やヘッドホンの線は別の色にしています。
404 では、ドットを塗らずに、1 つずつ枠だけの四角(中抜きの四角)で描きました。輪郭だけの、透けたような見た目になります。
<rect
x={pixel.x + inset}
y={pixel.y + inset}
width={1 - inset * 2}
height={1 - inset * 2}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
/>最初は、体の外周の辺だけを線でなぞる方法で作りました。ところが、ヘッドホンと体のあいだのすき間にも内側の輪郭ができて、線が入り組み、Friko に見えませんでした。ドットごとに四角を描く方法なら、もとのドット絵の形がそのまま残ります。
消えかけに見せる
消えかけに見せるために、3 つのことをしています。404 とエラーの画面のどちらも同じです。
- 足元ほど薄くする。不透明度を頭の上で 1、足元で 0.12 にし、そのあいだは高さの 2 乗に沿って下げる。下から先に消えていく
- 体のドットの 6% を描かず、欠けたように見せる。振り子とヘッドホンの線は細く、欠けると何の形かわからなくなるので、欠けさせるのは体だけにした
- ドットの 35% を、ゆっくり明滅させる。周期は 2.4〜6.4 秒で、ドットごとにずらす

どのドットを欠けさせ、どれを明滅させるかは、乱数ではなくドットの座標から決めています。乱数で選ぶと、サーバーで描いた HTML とブラウザで描き直した結果が食い違います。座標から決めれば両方が同じになり、開くたびに欠け方が変わることもありません。
const hashOf = ({ x, y }: FrikoPixel) => {
const seed = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
return seed - Math.floor(seed);
};目だけは塗りのまま残し、5 秒に 1 回まばたきさせています。頭の横に「?」を浮かべて、全体を 4 秒の周期で上下に漂わせ、迷子らしくしました。
動きは SVG の <animate> で付けています。CSS に @keyframes を書かなくても、コンポーネントの中だけで動きを書けるためです。ただし、<animate> は動きを減らす設定(prefers-reduced-motion)を見ません。そこでブラウザの設定を調べ、オンのときは <animate> を描かないようにしました。設定がわかるまでのあいだ(サーバーで描くときなど)は、止まった絵を出しています。
エラーの画面は泣き顔の Friko
表示の途中でエラーが起きたときは、目を「TT」(泣いている顔文字)にした泣き顔の Friko を出します。頭の横の印は「!」にしました。

「TT」は、左右の目それぞれの位置に「T」を 1 つずつ置いて描いています。「T」は、もとの目のドットがある列を中心にした横棒 3 ドットと、そこから下へ伸びる縦棒 2 ドットです。目のまわりの 3×3 のマスには体のドットを描かず、目が中抜きの四角に埋もれないようにしました。

最初は目を「><」にしていましたが、「TT」に変えました。頭の横の印も、最初は汗のしずくでした。ところが汗のしずくは、数ドットの大きさでは炎に見えてしまいました。そこで、404 の「?」と対になるよう「!」に変えています。
エラーの画面の Friko は、404 のように漂わずにじっとしていて、3 秒に 1 回ぶるっと震えます。
エラーの画面は 2 つある
Next.js では、エラーの画面を 2 つ置けます。
app/error.tsx: ページの中で起きたエラーを受け止める。すべてのページを包むレイアウト(ルートのレイアウト)の内側に出るので、翻訳やテーマはふだんどおり使えるapp/global-error.tsx: ルートのレイアウトの中でエラーが起き、レイアウトごと表示できなくなったときに出る。レイアウトの代わりに描くので、<html>と<body>を自分で置く
Next.js 16.3 からは、どちらにも retry が渡されます。そのページのデータを取り直して描き直す関数で、「もう一度試す」のボタンはこれを呼んでいます。
global-error.tsx は、翻訳(next-intl)とテーマ(next-themes)のプロバイダーの外で描かれます。そこで、文言・テーマ・アクセントの色を global-error.tsx の中で自分で決めるようにしました。
- 文言: 2 つのエラーの画面の文言を、アプリ全体の翻訳ファイルから別の JSON に分け、
global-error.tsxはその JSON を直接 import する。言語は、ユーザーがアプリで選んだ言語の Cookie で決め、なければブラウザの言語で決める - テーマ: next-themes が localStorage に覚えているテーマ(ライトかダークか)を取り出し、
<html>のdata-themeに入れる - アクセントの色: ユーザーが選んだ色の Cookie を見て、
data-accentに入れる
文言を別の JSON にしたのは、アプリ全体の翻訳ファイルを global-error.tsx で読み込むと、エラーの画面のためだけに大きなファイルを読み込むことになるためです。なお、プロバイダーの内側で描く error.tsx では、この JSON もほかの翻訳と一緒に読み込んで使っています。
エラーが起きると、error.tsx と global-error.tsx は、エラーの内容をログのサービスに送ります。
404 と、error.tsx・global-error.tsx の 3 つの画面は、同じ部品で作っています。違うのは、Friko の表情と文言、いちばん目立つボタンがホームへのリンクか「もう一度試す」かだけです。