• #クイズ
  • #UI/UX
  • #オンボーディング
  • #Vue.js
  • #localStorage
開発eurekapuメモ

クイズUI/UX改善 - オンボーディングモーダルからスケルトンUIまで

朝、初見ユーザーのつもりでクイズページを開いてみた。問題数191問、カテゴリ選択、ランダム出題。何がどう動くのか、説明がどこにもない。自分で作ったページなのに3秒ほど固まった。ここから「初めて触る人が迷わないUI」を一日かけて積み上げていった記録。

オンボーディングモーダルの設計と実装

4ステップの構成

初回訪問ユーザー向けに、4ステップのオンボーディングモーダルを設計した。

  • Step 1: 問題数の紹介。全191問、無料で68問、ログインすると+123問が解放される旨を表示
  • Step 2: 操作説明。実際のクイズUIに近いレイアウトで、選択肢をタップする動作を視覚的に伝える
  • Step 3: 解説画面の説明。正解・不正解後に表示される解説の見方を案内
  • Step 4: CTA。「さっそく始める」ボタンでモーダルを閉じてクイズ画面へ遷移

Step 2は当初テキストだけで説明していたが、実際のクイズUIに近いモックを配置する形に改善した。文字で「選択肢をタップします」と書くより、ボタンが並んでいるほうが手が動く。

localStorageによる表示制御

モーダルの表示制御に localStorage を使った。ポイントは dismissedcompleted を分離したこと。

  • dismissed: 「スキップ」で閉じた場合。次回訪問時に再表示する
  • completed: 最後のステップまで進んだ場合。以降は表示しない
// dismissed と completed を分離して管理
const STORAGE_KEY = 'quiz-onboarding'
type OnboardingState = { dismissed: boolean; completed: boolean }

「スキップした人は興味がないから二度と出さない」という設計も考えたが、初回は操作に不慣れでスキップしてしまうケースがある。completed だけを永続的な非表示条件にした。

worktreeでの実装

オンボーディングモーダルはworktreeで別ブランチを切って実装し、完成後にmainへpushした。既存のクイズ機能に影響を与えずに試行錯誤できた。

複数回答判定の修正

複数の正解がある問題で、1つ選んだ時点で不正解と判定されるバグがあった。原因は判定ロジックが選択のたびに即座に正誤を返していたこと。

修正後は、正解数と選択数が一致するまで pending を返すようにした。ユーザーが2つ選ぶべき問題で1つだけ選んだ段階では、まだ判定しない。全て選び終わった時点で初めて正誤を確定する。

選択ボタンとカードのスタイル変更

選択ボタン

選択済みボタンの背景色を黒から薄いブルー+青ボーダーに変更した。黒背景だと「確定した」ように見えてしまい、複数選択時に追加で選ぶ気にならない。薄いブルーにしたことで「まだ選択中」という状態が視覚的に伝わる。

「全範囲」カードの背景色

カテゴリ選択画面の「全範囲」カードの背景色をグレーから白に統一した。他のカテゴリカードと同じ白背景にすることで、特別扱いされている印象を消した。

未ログイン時の表示修正

未ログイン時に「全範囲」カードが191問と表示されていたが、実際にアクセスできるのは無料の68問だけだった。未ログイン時は68問と表示するよう修正した。数字が嘘をつくとユーザーの信頼を失う。

スケルトンUI・エラー状態・空状態の実装

practice、random、reviewの3画面に、ローディング・エラー・空の3状態を実装した。

スケルトンUI

データ取得中にコンテンツ領域が空白になる問題を、スケルトンUIで解消した。カードやリストのシルエットがパルスアニメーションで表示され、「読み込み中」であることが目に見える。

エラー状態

API呼び出し失敗時に「エラーが発生しました。再読み込みしてください」というメッセージとリトライボタンを表示するようにした。

空状態

問題が0件のカテゴリを選んだ場合に「このカテゴリにはまだ問題がありません」というメッセージを表示。以前は何も表示されず、壊れているのか空なのか区別がつかなかった。

Promise.all()による並列ロード

practice画面で、カテゴリ一覧の取得と問題数の取得を直列に実行していた。Promise.all() で並列化したところ、体感でローディング時間が短くなった。

// Before: 直列
const categories = await fetchCategories()
const counts = await fetchCounts()

// After: 並列
const [categories, counts] = await Promise.all([
  fetchCategories(),
  fetchCounts()
])

重複CSSのグローバル化

practice、random、reviewの3画面で同じボタンスタイルやカードスタイルが重複していた。共通CSSを app.vue に移動してグローバル化した。3画面で同じ修正を3回繰り返す未来が見えたので、このタイミングで統合した。

ログインプロンプトモーダルの改善

未ログイン状態でロック問題をタップした際に表示されるモーダルのデザインを改善した。

  • 不要な <br> タグを除去し、余白をCSSで制御
  • Google OAuthのアイコンをボタンに追加
  • Google OAuthの認証フローが正しく動作することを確認

ProductId重複インポートの解消

複数ファイルで ProductId 型を個別に定義・インポートしていた。共通の型定義ファイルから一箇所でエクスポートする形に整理した。

データ構造の改善

クイズの選択肢を文字列で管理していたが、{account, amount} のオブジェクト形式に分離した。

// Before: 文字列結合
choices: ["現金 100,000", "売上 100,000"]

// After: 構造化
choices: [
  { account: "現金", amount: 100000 },
  { account: "売上", amount: 100000 }
]

勘定科目と金額を別フィールドに持つことで、金額のフォーマット変更や勘定科目の検索が文字列パースなしで行える。

振り返り

一日でオンボーディングモーダル、判定ロジック、3画面のスケルトンUI、CSS統合と幅広く手を入れた。どれも「初めて触る人が何に戸惑うか」を起点にした改善だった。

特にオンボーディングモーダルのStep 2を、テキスト説明から実際のUI風モックに切り替えた判断は正解だった。操作説明は「読ませる」より「見せる」方が伝わる。dismissed/completedの分離も、ユーザー行動をもう一段掘り下げて考えた結果で、この粒度の設計判断を積み重ねることが使い勝手に効いてくる。

スケルトンUIとエラー状態は、実装コストに対してユーザー体験の改善幅が大きい。「何も表示されない」状態を放置すると、ユーザーはページが壊れたと思って離脱する。ローディング・エラー・空の3状態を網羅的に実装しておくと、どんな状況でもユーザーに「今何が起きているか」を伝えられる。