404ページを「行き止まり」ではなく「帰り道」にしたかった
存在しないURLへアクセスしたとき、多くのサイトは短いエラー文とトップページへのリンクだけを表示します。最低限の役割は果たしていますが、せっかくサイトへ来てもらったのに、最後の画面だけ急に無機質になるのが気になっていました。
以前の404ページも、HTTPステータスやアクセス先をコンソール風に表示する構成でした。技術サイトらしさはあるものの、サイト全体で使っている海、朝焼け、夜の海といったモチーフからは少し離れています。また、「エラーが発生した」という説明はできても、「ここからどこへ行けるのか」が視覚的に伝わりにくい状態でした。
そこで今回は、404を故障画面ではなく海上で現在地を見失い、安全なルートを再探索している画面として作り直しました。
目標は次の5つです。
- HTTPとして本当に
404 Not Foundを返す - サイトの海モチーフとライト・ダークテーマに馴染ませる
- HOME、ARTICLES、現在地の関係を一目で伝える
- 動かしても重くせず、画面外では停止する
- アニメーションが苦手な人やスクリーンリーダーにも配慮する
見た目だけではなく、サーバー、HTML、CSS、JavaScriptを分けて考えることにしました。
完成した構成
画面の左側には大きな404と説明、右側にはROUTE MAPを配置しています。ルートマップの中には、次の要素を重ねました。
- HOME、404、ARTICLESの3地点
- 3地点をつなぐ曲線ルート
- ルート上を進むパケット
- レーダーの回転スイープ
- 外側へ広がるソナーリング
- 上から下へ移動する走査線
- HTTPメソッドや安全なリンク数を示すテレメトリー
- 「探索中」から「移動先を用意しました」へ変わるステータス
単に装飾を増やすのではなく、「URLを探索し、見つからなかったため、安全な移動先を案内する」という一つの流れに揃えています。複数の動きに意味があると、情報量が増えても散らかって見えにくくなります。
Expressで正しい404レスポンスを返す
最初に守ったのは、404ページを表示するときにステータスコードも404にすることです。
画面に「404」と書いてあっても、サーバーが200 OKを返していれば正しい404ページとはいえません。検索エンジンや監視ツールからは正常ページに見え、ソフト404として扱われる可能性もあります。
Express側では、404用HTMLを組み立てる関数と、レスポンスを返す関数を分けました。
function sendNotFound(req, res) {
if (req.accepts("html") === false) {
res.status(404)
.type("text/plain")
.send(`404 Not Found\nCannot ${req.method} ${req.originalUrl}`);
return;
}
res.status(404)
.type("html")
.send(injectDynamicSite(notFoundHtml(req), "site-not-found"));
}
HTMLを受け取れるブラウザにはデザイン済みページを返し、HTMLを期待しないクライアントには短いプレーンテキストを返します。APIクライアントにまで大きなHTMLを返さないための分岐です。
このハンドラーは、通常のルートや静的ファイル配信より後ろへ置きます。
app.get("/", homeHandler);
app.get("/articles/:slug", articleHandler);
app.use(express.static(PUBLIC_DIR));
// 最後まで一致しなかったリクエストだけを受け取る
app.use(sendNotFound);
Expressは上から順番にルートを評価します。404処理を先に置くと、存在するページまで404になるため、配置順は重要です。
入力されたURLをそのままHTMLへ入れない
今回の画面では、探索対象としてアクセスされたパスを表示します。ここでreq.originalUrlをそのままHTMLへ埋め込むのは避けました。
クエリ文字列には、検索語だけでなく、認証用トークンやメールアドレスなどが含まれる場合があります。また、非常に長いURLを表示するとレイアウトが壊れます。
実装では、クエリを含まないreq.pathを使い、表示長を96文字までに制限しています。
const method = String(req.method || "GET")
.toUpperCase()
.slice(0, 12);
const rawRequestedPath = String(req.path || "/");
const requestedPath = rawRequestedPath.length > 96
? `${rawRequestedPath.slice(0, 95)}…`
: rawRequestedPath;
const statusLine = `${method} ${requestedPath}`;
HTMLへ入れる直前には、さらにエスケープします。
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/\"/g, """)
.replace(/'/g, "'");
}
「自分のサイト内に表示するだけだから安全」と考えず、URLも外部入力として扱うのが大切です。
404ページには次の指定も入れました。
<meta name="robots" content="noindex, nofollow">
存在しないURLそのものを検索結果へ残す必要はないためです。
レーダー画面はCSSのレイヤーで作る
ルートマップは1枚の大きな画像ではなく、HTMLとCSSのレイヤーで組み立てています。
<aside class="not-found-radar" aria-labelledby="routeRecoveryTitle">
<div class="not-found-radar__top">...</div>
<div class="not-found-radar__scene" aria-hidden="true">
<span class="not-found-radar__grid"></span>
<span class="not-found-radar__scanline"></span>
<span class="not-found-radar__sweep"></span>
<span class="not-found-radar__ring"></span>
<svg class="not-found-radar__route">...</svg>
</div>
<div class="not-found-radar__telemetry">...</div>
</aside>
背景グリッドは、縦と横のlinear-gradient()を重ねるだけで作れます。
.not-found-radar__grid {
position: absolute;
inset: -24px;
background-image:
linear-gradient(rgba(0, 111, 150, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 111, 150, 0.12) 1px, transparent 1px);
background-size: 32px 32px;
transform: perspective(360px) rotateX(20deg) scale(1.1);
}
少し傾けることで、平面の方眼ではなく、奥行きのある海図のように見せています。
レーダーの扇形はconic-gradient()、回転はtransformで実装しました。
.not-found-radar__sweep {
position: absolute;
top: 50%;
left: 50%;
width: 145%;
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(
from 12deg,
transparent 0 76%,
rgba(42, 184, 219, 0.04) 82%,
rgba(42, 184, 219, 0.3) 98%,
transparent
);
animation: radar-sweep 6s linear infinite;
}
@keyframes radar-sweep {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
topやleftを毎フレーム変えるより、transformとopacityを中心に動かすほうが、ブラウザが合成レイヤーとして処理しやすくなります。
曲線ルートはSVG、周囲のUIはCSS
HOMEから404を通ってARTICLESへ向かう線にはSVGを使いました。複雑な曲線をCSSだけで作るより、SVGのベジェ曲線のほうが形を調整しやすいためです。
<svg viewBox="0 0 420 260" preserveAspectRatio="none">
<path
d="M34 208 C112 192 96 92 186 116
S282 218 386 48">
</path>
<circle r="6">
<animateMotion
dur="6s"
repeatCount="indefinite"
path="M34 208 C112 192 96 92 186 116
S282 218 386 48">
</animateMotion>
</circle>
</svg>
同じパスを2本重ね、下の線を太くぼかして発光、上の線を細い破線にしています。そこへanimateMotionで小さな円を動かすと、「目的地までデータを送っている」ような表現になります。
重要なのは、SVGにすべてを詰め込まなかったことです。
- 曲線と経路上の移動はSVG
- グリッド、走査線、リング、カードはCSS
- 文言変更、マウス追従、停止制御はJavaScript
それぞれの得意分野へ分けることで、後から色、速度、文言を変更しやすくなりました。
すべてのアニメーションを6秒の探索周期へ揃える
動く部品を個別に追加すると、それぞれが勝手な速度で動き、騒がしい画面になりがちです。今回はレーダー1回転とパケットの移動を6秒に揃えました。走査線も同じ周期を基準にしています。
ソナーリングは3秒周期にし、2つのリングを1.5秒ずらしました。6秒の大きな周期の中で、3秒の小さな周期が2回繰り返されます。
.not-found-radar__ring {
animation: sonar 3s ease-out infinite;
}
.not-found-radar__ring--two {
animation-delay: 1.5s;
}
アニメーションの品質は、要素数よりも周期と意味が揃っているかで大きく変わります。
マウス追従はrequestAnimationFrameで1フレーム1回に制限
PCでは、レーダーカード上でマウスを動かすと、カードが少し傾き、光の位置も追従します。
pointermoveは短時間に大量発生します。イベントのたびにgetBoundingClientRect()とスタイル更新を行うと、不要な計算が増えます。そこで座標だけを保存し、描画更新はrequestAnimationFrame()で1フレームに1回までにしました。
let tiltFrame = 0;
let pendingPointer = null;
radar.addEventListener("pointermove", (event) => {
if (event.pointerType === "touch") return;
pendingPointer = { x: event.clientX, y: event.clientY };
if (tiltFrame) return;
tiltFrame = requestAnimationFrame(() => {
tiltFrame = 0;
const rect = radar.getBoundingClientRect();
const x = (pendingPointer.x - rect.left) / rect.width;
const y = (pendingPointer.y - rect.top) / rect.height;
radar.style.setProperty("--radar-tilt-x", `${-(y - 0.5) * 3.2}deg`);
radar.style.setProperty("--radar-tilt-y", `${(x - 0.5) * 4}deg`);
});
}, { passive: true });
傾きは縦方向で最大約1.6度、横方向で最大約2度に抑えています。大きく動かすと操作感より酔いやすさが勝ってしまうため、「気づくが邪魔にならない」程度にしました。
タッチ操作では追従を無効にしています。スマートフォンではホバーの概念がなく、スクロール中にカードが動くと操作を邪魔するからです。
画面外とバックグラウンドでは止める
404ページは長時間開き続けるページではありません。それでも、見えていないアニメーションへCPUやGPUを使い続ける理由はありません。
そこでIntersectionObserverでレーダーが画面内にあるかを監視しています。
const observer = new IntersectionObserver((entries) => {
inViewport = entries.some((entry) => entry.isIntersecting);
syncRadarState();
}, {
rootMargin: "120px 0px",
threshold: 0.01
});
observer.observe(radar);
さらにPage Visibility APIのdocument.hiddenも確認します。
function syncRadarState() {
if (inViewport && !document.hidden) startRadar();
else stopRadar();
}
document.addEventListener("visibilitychange", syncRadarState);
停止時はCSSアニメーションをpausedにし、SVGのアニメーションもpauseAnimations()で止めます。タブへ戻ったときはunpauseAnimations()で再開します。
.not-found-radar:not(.is-active) .not-found-radar__sweep,
.not-found-radar:not(.is-active) .not-found-radar__ring,
.not-found-radar:not(.is-active) .not-found-radar__progress span {
animation-play-state: paused;
}
小さな演出でも、停止条件まで作って初めて完成と考えています。
prefers-reduced-motionへ対応する
OSで「視差効果を減らす」「アニメーションを減らす」を選んでいる利用者には、動きを強制しないようにしました。
@media (prefers-reduced-motion: reduce) {
.not-found-radar__scanline,
.not-found-radar__sweep,
.not-found-radar__ring,
.not-found-radar__route path,
.not-found-radar__progress span {
animation: none;
}
.not-found-radar__packet {
display: none;
}
}
JavaScript側でも同じ設定を確認し、カードのマウス追従を初期化します。アニメーションを消しても、HOMEやARTICLESへのリンク、404の説明、アクセスされたパスはそのまま読めます。
演出をオフにすると情報まで消える設計は避けるべきです。
スクリーンリーダーへ何を読ませるか
レーダー内のグリッドや走査線は、視覚的な説明のための装飾です。これらを一つずつ読み上げても意味がないので、シーン全体へaria-hidden="true"を付けました。
一方、現在の探索状況は変化に意味があるため、1個のライブリージョンを使っています。
<div class="not-found-radar__status" role="status" aria-live="polite">
<span id="routeStatus">周辺ルートを探索中</span>
</div>
JavaScriptでは、この同じ要素のテキストだけを次の順番で変更します。
周辺ルートを探索中
安全な移動先を照合中
移動先を用意しました
ライブリージョンを複数作ると、スクリーンリーダーが何度も読み上げる可能性があります。状態通知は一か所へまとめ、aria-live="polite"で現在の読み上げを無理に遮らないようにしました。
スマートフォンでは情報の優先順位を変える
PCでは説明とレーダーを2カラムで表示しますが、画面幅が狭いときは1カラムへ切り替えます。
移動ボタンは、最も重要な「トップへ戻る」を1段目いっぱいにし、「記事一覧」と「白海しえる」を2段目に並べました。
@media screen and (max-width: 640px) {
.not-found-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.not-found-actions .dynamic-button:first-child {
grid-column: 1 / -1;
}
.not-found-radar__readout p {
grid-template-columns: 1fr;
}
}
単純に全要素を小さくするのではなく、重要な操作へ広い面積を渡しています。モバイル対応は縮小ではなく、情報設計の組み替えだと感じました。
ライトモードとダークモードを別々に作り込む
ライトモードは昼の浅い海、ダークモードは夜の深い海をイメージしています。ただ背景を白と黒へ切り替えるだけではなく、発光、境界線、文字のコントラストもテーマ変数で調整しました。
.not-found-radar__scene {
background:
radial-gradient(circle at 50% 50%, rgba(0, 141, 178, 0.15), transparent 48%),
linear-gradient(160deg, rgba(224, 247, 251, 0.8), rgba(188, 228, 239, 0.48));
}
html[data-theme="dark"] .not-found-radar__scene {
background:
radial-gradient(circle at 50% 50%, rgba(59, 198, 235, 0.16), transparent 48%),
linear-gradient(160deg, rgba(5, 29, 46, 0.94), rgba(2, 13, 29, 0.88));
}
昼はパネルと背景が溶けすぎないよう境界線を残し、夜はシアンの発光が強く見えすぎないよう透明度を落としています。同じアクセントカラーでも、周囲の明度によって見え方はかなり変わります。
404専用ローダーは短くする
サイト全体には、通常モードでは朝日、ダークモードでは月が昇るローディング演出があります。しかし404ページで長いローダーを見せると、利用者は「ページを読み込んでいる」と待ったあとに「見つからなかった」と知らされます。
これは体験として遅いため、404ではローダーを約0.8秒へ短縮しました。
404ページの役割は、豪華な演出を最後まで見せることではなく、状況をすぐ伝えて次の移動先を用意することです。演出を作り込んでも、待ち時間まで長くする必要はありません。
動作確認で見たポイント
完成後は、見た目だけでなく次の項目を確認しました。
| 確認項目 | 期待する結果 |
|---|---|
| 存在しないURL | HTTPステータスが404になる |
| 通常ページ | 404処理へ吸い込まれず200になる |
| 長いURL | 96文字で省略されレイアウトが壊れない |
| クエリ付きURL | クエリ内容を画面へ表示しない |
| HTMLを受けないリクエスト | プレーンテキストの404を返す |
| ライト・ダーク切り替え | 背景、文字、発光が両方で読める |
| スマートフォン幅 | 横スクロールせず、ボタンを押しやすい |
| タブを非表示 | レーダーの動きとタイマーが止まる |
| reduced motion | 装飾アニメーションが停止する |
| キーボード操作 | 3つの移動リンクへ順番にフォーカスできる |
Node.js側は最低限、構文チェックも通します。
npm.cmd test
ローカルで存在しないパスへアクセスし、レスポンスヘッダーを確認する場合は次のようにできます。
curl.exe -I http://127.0.0.1:8000/this-page-does-not-exist
先頭行がHTTP/1.1 404 Not Foundなら、見た目だけの404にはなっていません。
作って分かったこと
今回の改善で一番大きかったのは、404ページを「余ったページ」ではなく、サイト内の導線として考え直したことです。
作り込む前は、アニメーションを増やせば品質が上がるように思えました。しかし実際には、重要だったのは次の4点です。
- エラーの理由がすぐ分かる
- 次に選べる移動先が明確
- サイト全体の世界観が途切れない
- 動きが利用者や端末の負担にならない
レーダー、ソナー、走査線、パケットは、その4点を支える範囲で使っています。
404ページは、利用者が予定外の場所へ到着した瞬間に表示されます。そのため、通常ページ以上に「何が起きたか」と「次にどうすればよいか」を短時間で伝える必要があります。
エラーを隠すのではなく、エラーをサイトらしい体験へ変えつつ、正しいHTTPレスポンス、安全な文字列処理、アクセシビリティ、停止制御まで揃える。今回の404ページ制作は、小さな1ページの中にWeb制作の基本がかなり詰まった改善になりました。