mcpField
S
saas-product

saas-product

github.com/tgautierデザイン・UI237 回インストール1 日前に更新
リポジトリ

saas-product について

SaaS 機能を設計する人向けのプロダクト設計手法です。オンボーディング、空状態、ダッシュボード、通知、サブスクリプション、機能制限を扱い、段階的な複雑さとユーザーの目的を重視します。

主な機能

  1. 01段階的な複雑さとオンボーディング
  2. 02空状態とダッシュボードの設計パターン
  3. 03ローディング、遷移、楽観的 UI
  4. 04通知チャネルと設定
  5. 05サブスクリプション UX と機能制限

活用シーン

  1. 01新規ユーザーの初回利用フローの計画
  2. 02データなしや読み込み中の画面設計
  3. 03SaaS 指標ダッシュボードの構築
  4. 04サブスクリプション更新と機能制限の設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tgautier/dotfiles --skill saas-product

インストール後、Claude Code で/saas-productを入力して読み込まれたか確認します。

詳細

SaaS プロダクトデザイン

ユーザーの利用定着と継続利用を促進する、本番環境向け SaaS 機能の構築手法です。価値実現までの時間を短縮し、マルチテナント型のサブスクリプションベースアプリケーションの複雑さに対応するパターンに重点を置いています。

デザインシステムのトークンとアクセシビリティについては、/ux-design を参照してください。React コンポーネントのパターンについては、/react を参照してください。機能の背後にある API コントラクトについては、/api-design を参照してください。ドメインモデリングについては、/domain-design を参照してください。


1. デザイン哲学

段階的な複雑さ

まずシンプルに始め、ユーザーの必要に応じて複雑さを明らかにします。新規ユーザーは、60 秒以内に最初の価値を実感できるようにします。高度な機能は段階的に利用可能にします。

原則適用方法
価値実現までの時間サインアップから最初の意味のある操作までのステップを最小限にします
段階的な情報開示高度なオプションは「Advanced」トグルやサブメニューの背後に隠します
達成すべきジョブデータエンティティではなく、ユーザーが達成しようとしていることを中心に設計します
適切なデフォルト最も一般的な選択肢を設定に事前入力し、デフォルトの操作経路が正しく機能するようにします
確認よりも取り消し操作の流れを中断する確認ダイアログより、元に戻せる操作と取り消しを優先します
プロダクトの階層
Feature → Page → Section → Component

各レベルには明確な責任があります。

  • 機能:一連の機能(例:「資産管理」)
  • ページ:機能内のビュー(例:「資産一覧」、「資産詳細」)
  • セクション:ページ内の論理的なグループ(例:「パフォーマンスチャート」、「取引履歴」)
  • コンポーネント:再利用可能な UI 要素(例:「通貨バッジ」、「日付ピッカー」)

2. オンボーディングと初回利用

アクティベーション指標

オンボーディングを構築する前に、「アクティベーション済み」の定義を決めます。

指標例
セットアップ完了ユーザーが少なくとも 1 つのデータソースを接続している
初めての価値ユーザーが実データを含む最初のダッシュボードを閲覧している
習慣の形成ユーザーが最初の 7 日間に 3 回再訪している
段階的なオンボーディングのパターン

セットアップウィザード — 初期設定が必要なプロダクト向け:

// Loader returns current step from session/DB
export async function loader({ request }: LoaderFunctionArgs) {
  const progress = await getOnboardingProgress(request);
  return { step: progress.currentStep, steps: progress.steps };
}

// Action validates current step, saves, and advances
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  if (formData.get("_intent") === "skip") return redirect("/app");
  const nextStep = await saveStepAndAdvance(formData);
  if (nextStep === "complete") return redirect("/app");
  return redirect(`/onboarding/step/${nextStep}`);
}

// Component renders the current step as a form
function SetupWizard() {
  const { step, steps } = useLoaderData<typeof loader>();
  return (
    <div>
      <StepIndicator steps={steps} current={step} />
      <Form method="post">
        <CurrentStepFields step={step} />
        <Button type="submit">Continue</Button>
      </Form>
      <Form method="post">
        <input type="hidden" name="_intent" value="skip" />
        <button type="submit" className="text-sm text-muted-foreground">
          Skip setup — I'll do this later
        </button>
      </Form>
    </div>
  );
}

ルール:

  • 常にスキップできるようにし、すべてのステップの完了を強制しない
  • 進捗は自動保存する。各ステップの操作内容は送信され、サーバー側に永続化される
  • ステップ数は最大 3~5 にする。それ以上になると、ユーザーは途中で離脱します
  • 進捗を明確に表示する(4 ステップ中 2 ステップ目)

チェックリストパターン — セットアップを段階的に進めるプロダクト向け:

function OnboardingChecklist({ tasks }: { tasks: OnboardingTask[] }) {
  const completed = tasks.filter(t => t.done).length;
  return (
    <Card>
      <Progress value={completed} max={tasks.length} />
      <p>{completed} of {tasks.length} complete</p>
      {tasks.map(task => (
        <ChecklistItem key={task.id} task={task} />
      ))}
      {completed === tasks.length && <DismissButton />}
    </Card>
  );
}

コンテキストに応じたツールチップ — 初期セットアップ後の機能発見向け:

  • ユーザーごとに 1 回だけ表示し、閉じたことをユーザー設定に記録する
  • 一般的な領域ではなく、特定の UI 要素を指し示す
  • 明確な CTA を 1 つだけ含める(「今すぐ試す」/「了解」)
アンチパターン
  • 強制的な動画ツアー(ユーザーはスキップします)
  • すべての要素に同時に表示するツールチップ(ユーザーを圧倒します)
  • オンボーディングが完了するまでアプリをブロックする(離脱を招きます)
  • すでに完了したリピーターにオンボーディングを表示する

3. 空の状態

データを扱うビューでは、次の 4 つの空の状態に対応する必要があります。

種類状況内容
初回利用ユーザーがまだデータを作成していないイラスト+説明+主要 CTA
検索結果なし検索またはフィルターの結果がない「X の検索結果はありません」+検索範囲を広げる提案
エラーデータの読み込みに失敗したエラーメッセージ+再試行ボタン
フィルター適用後に結果なし適用したフィルターですべての結果が除外された適用中のフィルター+「フィルターをクリア」ボタンを表示
初回利用時の空の状態のパターン
function EmptyState({ icon, title, description, action }: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center py-16 text-center">
      <div className="mb-4 text-muted-foreground">{icon}</div>
      <h3 className="text-lg font-semibold">{title}</h3>
      <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
      {action && (
        <Button className="mt-4" asChild>
          <Link to={action.href}>{action.label}</Link>
        </Button>
      )}
    </div>
  );
}

// Usage — CTA navigates to a route, not an onClick handler
<EmptyState
  icon={<WalletIcon size={48} />}
  title="No assets yet"
  description="Add your first asset to start tracking your portfolio performance."
  action={{ href: "/assets/new", label: "Add Asset" }}
/>

ルール:

  • 初回利用時の空の状態には、最初の項目の作成につながる CTA を含める
  • 何もないページや、単独の「データがありません」というメッセージを表示しない
  • イラストやアイコンを使い、空の状態が意図的なものだと伝える。壊れているように見せない
  • CTA は明確な操作 1 つに絞り、複数の選択肢を提示しない

4. ダッシュボードのデザイン

KPI カードの構成

適切に設計された KPI カードには、現在の値、トレンド指標、比較期間、任意のスパークラインを表示します。

interface KPICardProps {
  label: string;
  value: string;
  change: number;      // percentage change
  period: string;      // "vs last month"
  sparklineData?: number[];
}

function KPICard({ label, value, change, period, sparklineData }: KPICardProps) {
  const isPositive = change >= 0;
  return (
    <Card>
      <p className="text-sm text-muted-foreground">{label}</p>
      <p className="text-2xl font-bold">{value}</p>
      <div className="flex items-center gap-1 text-sm">
        <TrendIcon direction={isPositive ? "up" : "down"} />
        <span className={isPositive ? "text-green-600" : "text-red-600"}>
          {Math.abs(change)}%
        </span>
        <span className="text-muted-foreground">{period}</span>
      </div>
      {sparklineData && <Sparkline data={sparklineData} />}
    </Card>
  );
}
グラフ選択ガイド
データの関係性グラフの種類使用する場面
全体に対する割合ドーナツ(最大5区分)予算配分、ポートフォリオ構成
時間の経過に伴う変化折れ線/エリア収益の推移、成長指標
比較横棒カテゴリの比較、ランキング
分布ヒストグラム値の範囲、頻度
時間の経過に伴う構成積み上げエリア時間の経過に伴うセグメント別収益

ルール:

  • 区分が5つを超える場合、円グラフは使用しないでください。横棒に切り替えてください
  • 3D グラフは使用しないでください
  • 折れ線グラフには連続した x 軸(時間、順序)が必要です
  • 必ず軸にラベルを付け、単位を含めてください
ダッシュボードのレイアウト
  • 上段:最も重要な指標を要約する KPI カードを3~4枚
  • 中段:主要なグラフ(全幅または幅の2/3)
  • 下段:補助データテーブルまたは詳細ビュー
  • レイアウトには CSS Grid を使用します。KPI 行には grid-cols-1 md:grid-cols-2 lg:grid-cols-4 を使用します
データ密度
  • パワーユーザー向けの高密度表示(列数の多いテーブル、コンパクトな間隔)
  • ライトユーザー向けの要約ビュー(KPI カード、スパークライン、簡略化したグラフ)
  • ユーザーがビューを切り替えられるようにするか、設定を記憶してください

5. 読み込みと遷移状態

スケルトン画面

スケルトンの形状を実際のコンテンツのレイアウトに合わせてください。ユーザーはスケルトン画面をスピナーよりも速く感じます。

function AssetListSkeleton() {
  return (
    <div className="space-y-3">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="flex items-center gap-4">
          <Skeleton className="h-10 w-10 rounded-full" />
          <div className="space-y-2">
            <Skeleton className="h-4 w-48" />
            <Skeleton className="h-3 w-32" />
          </div>
          <Skeleton className="ml-auto h-4 w-20" />
        </div>
      ))}
    </div>
  );
}
読み込み状態の判断基準
所要時間パターン
< 100msインジケーターは不要
100-300ms控えめなインラインインジケーター(ボタンのスピナー)
300ms-2sスケルトン画面
2-10sプログレスバー、またはメッセージ付きスケルトン
> 10sバックグラウンドタスクとして実行し、完了時に通知
楽観的 UI

失敗することがまれな変更処理では、予想される結果をすぐに表示します。React Router v7 では、保留中の送信データである fetcher.formData から楽観的状態を導出します。保留中の項目はデータリストとは別に描画します。楽観的な項目は一時的な UI 状態であり、データではありません。サーバーは loader の再検証を通じて実際の ID を割り当てます。

function TodoList({ items }: { items: Todo[] }) {
  const fetcher = useFetcher();

  return (
    <>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
        {fetcher.formData && (
          <li className="opacity-50">
            {String(fetcher.formData.get("name") ?? "")}
          </li>
        )}
      </ul>
      <fetcher.Form method="post">
        <input type="hidden" name="_intent" value="create" />
        <input name="name" required />
        <Button type="submit">Add</Button>
      </fetcher.Form>
    </>
  );
}
  • 送信の処理中は fetcher.formData が null 以外になるため、そこから楽観的な項目を直接導出します
  • action の完了後、loader が再検証され、items が実データで更新され、fetcher.formData は null にリセットされます
  • 失敗時も loader は変更されていないデータで再検証されます。fetcher.formData が null になるため、楽観的な項目は消えます
  • useOptimistic、onSubmit、startTransition は不要です。ライフサイクルは React Router のデータレイヤーが処理します
ストリーミング SSR

高速なデータソースと低速なデータソースが混在するページでは:

  • 高速なデータ(ナビゲーション、レイアウト)はシェル内にすぐに描画されます
  • 低速なデータ(分析、外部 API)は Suspense 境界を介してストリーミングされます
  • 各 Suspense 境界では、読み込み中に専用のスケルトンが表示されます
サーバー優先の読み込み原則

ルート loader を使うと、ページの描画時にはデータが利用可能なため、初期データの読み込みにスピナーは必要ありません。スケルトン画面は次の場合に使用してください:

  • ストリーミング SSR(Suspense 境界の背後にある低速なデータソース)
  • Fetcher による更新(ナビゲーションを伴わない変更処理の実行中)
  • マウントガードが必要なクライアント専用コンポーネント

6. 通知の設計

通知チャネル
チャネル用途緊急度
アプリ内トースト操作の確認、軽微なエラー低 — 5秒後に自動的に閉じます
アプリ内ベル新しいアクティビティ、ステータスの変更中 — 既読になるまで表示されます
メールトランザクション通知(領収書、招待)、ダイジェスト低 — 可能な場合はまとめて送信します
ブラウザープッシュ通知時間に敏感なアラートのみ高 — 作業を中断します
トーストのベストプラクティス
// Success toast with undo
showToast("Asset deleted", {
  action: { label: "Undo", onClick: undoDelete },
  duration: 5000,
});

// Error toast — persists until dismissed
showToast("Failed to save changes. Please try again.", {
  variant: "error",
  duration: Infinity,
  action: { label: "Retry", onClick: retryAction },
});

ルール:

  • 成功のトーストは5秒後に自動的に閉じます
  • エラーのトーストは自動的に閉じないでください。ユーザーが気付かない可能性があります
  • 破壊的な操作には取り消しアクションを用意してください
  • 複数のトーストは縦に積み重ね、同時に表示する数は3つまでにしてください
  • フォームのバリデーションエラーを示す唯一の手段として、トーストを使用しないでください
通知設定

チャネルとイベントのマトリクスとして実装します。

イベントアプリ内メールプッシュ
新しいチームメンバーデフォルトでオンデフォルトでオンデフォルトでオフ
週次ダイジェスト該当なしデフォルトでオン該当なし
支払い失敗デフォルトでオンデフォルトでオンデフォルトでオン
機能アップデートデフォルトでオンデフォルトでオフデフォルトでオフ

ユーザーが各セルを個別に設定できるようにします。


7. 課金とサブスクリプションの UX

プランの比較
function PlanComparison({ plans }: { plans: Plan[] }) {
  return (
    <div className="grid gap-6 md:grid-cols-3">
      {plans.map(plan => (
        <PlanCard
          key={plan.id}
          name={plan.name}
          price={plan.price}
          period={plan.period}
          features={plan.features}
          recommended={plan.recommended}
          current={plan.current}
        />
      ))}
    </div>
  );
}

ルール:

  • 推奨プランを視覚的に強調します(枠線、バッジ、「最も人気」など)
  • 現在のプランを明確に表示し、ユーザーが自分の状況を把握できるようにします
  • プランごとに機能をチェックマークで一覧表示し、利用できる機能と利用できない機能の両方を示します
  • 年額料金には、月額換算額と割引率を表示します
アップグレードの案内

状況に応じた案内は、一般的なアップセルバナーよりも効果が3倍高くなります。

// GOOD — contextual, shown when the user hits a limit
function FeatureLimitPrompt({ feature, limit, current }: LimitPromptProps) {
  return (
    <Alert>
      <p>You've used {current} of {limit} {feature}.</p>
      <Button variant="link" asChild>
        <Link to="/settings/billing">Upgrade for unlimited {feature}</Link>
      </Button>
    </Alert>
  );
}

// BAD — generic banner shown on every page
<Banner>Upgrade to Pro for more features!</Banner>
トライアルとダウングレード
  • トライアルの残り日数は、控えめで常に表示されるインジケーターで示します(ポップアップにはしません)
  • ダウングレードの前に、機能一覧だけでなく、ユーザーが失うものを示します
  • ダウングレード後は、機能を削除せず、読み取り専用にするなど適切に制限します
  • ダウングレード時にユーザーデータを削除してはいけません。アクセス不可としてマークし、再アップグレードできるようにします

8. 機能の制限

実装パターン
パターン使用する場面
機能フラグ新機能を段階的にユーザーへ展開する場合(ユーザーの一部に適用)
プランによる制限特定のサブスクリプションプランでのみ機能を利用できる場合
ロールによる制限特定のユーザーロール(管理者、メンバー)に機能を制限する場合
使用量制限機能に請求期間ごとの使用枠がある場合
段階的な機能制限

機能が制限されている場合、ユーザーに利用できない機能と利用方法を示します。

// Check access in the loader — never send gated data to the client
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireAuth(request);
  const access = await checkFeatureAccess(user, "advanced-analytics");
  if (!access.granted) {
    return { gated: true, requiredPlan: access.requiredPlan };
  }
  const data = await loadAnalytics();
  return { gated: false, data };
}

// Component renders based on loader data
function AnalyticsPage() {
  const loaderData = useLoaderData<typeof loader>();
  if (loaderData.gated) {
    return <UpgradeOverlay requiredPlan={loaderData.requiredPlan} />;
  }
  return <AnalyticsDashboard data={loaderData.data} />;
}

ルール:

  • 制限された機能がある場所を空白にせず、プレビューを表示します
  • 制限された機能を完全に非表示にしてはいけません。機能を知ってもらうことがアップグレードにつながります
  • エラーメッセージではなく、ぼかしたプレビューやロックアイコンを使用します
  • ロールで制限された機能には、アップグレードの案内ではなく「管理者にお問い合わせください」というメッセージを表示します

9. 設定と管理者向け UX

設定の構成
セクション内容
アカウントプロフィール、メールアドレス、パスワード、2FA
チームメンバー、招待、ロール
課金プラン、支払い方法、請求書
設定テーマ、言語、通知設定
インテグレーション接続済みサービス、API キー
危険な操作アカウントの削除、データのエクスポート
危険な操作

破壊的な設定は視覚的に区別し、確認を必須にします。

// Action — server-side validation (client-side pattern is bypassable)
export async function action({ request }: ActionFunctionArgs) {
  const user = await requireAuth(request);
  const formData = await request.formData();
  if (formData.get("_intent") === "delete-account") {
    const confirmation = String(formData.get("confirmation") ?? "");
    if (confirmation !== "delete my account") {
      return Response.json(
        { error: "Confirmation phrase does not match", intent: "delete-account" },
        { status: 400 },
      );
    }
    await api.deleteAccount(user.id);
    return redirect("/goodbye");
  }
}

// Component
function DangerZone() {
  return (
    <Card className="border-red-200 bg-red-50">
      <h3 className="text-red-900">Danger Zone</h3>
      <div className="space-y-4">
        <Form method="post">
          <input type="hidden" name="_intent" value="delete-account" />
          <p className="text-sm">Permanently delete your account and all data. This cannot be undone.</p>
          <label htmlFor="delete-confirmation" className="text-sm font-medium">
            Type "delete my account" to confirm
          </label>
          <input
            id="delete-confirmation"
            name="confirmation"
            required
            pattern="delete my account"
          />
          <Button type="submit" variant="destructive">Delete account</Button>
        </Form>
      </div>
    </Card>
  );
}

ルール:

  • 危険な操作のセクションには赤い枠線または背景を使用します
  • 元に戻せない操作では、確認フレーズの入力を必須にします
  • サーバーアクションで確認値を検証します。クライアント側の pattern は UX 上のヒントであり、セキュリティ上の境界ではありません
  • 削除または失われる内容を明確に説明します
  • アカウントを削除する前に、データをエクスポートできるようにします

10. 監査ログとアクティビティフィード

フィードの構造

監査ログの各項目では、誰が、何を、どのリソースに対して、いつ行ったかがわかるようにします。

interface AuditEntry {
  id: string;
  actor: { id: string; name: string; avatar?: string };
  action: string;         // "created" | "updated" | "deleted" | "exported"
  resource: { type: string; id: string; name: string };
  changes?: FieldChange[];
  timestamp: string;      // ISO 8601
}

interface FieldChange {
  field: string;
  from: string | null;
  to: string | null;
}
表示パターン
  • 日付の見出しを付けて、項目を日ごとにまとめます
  • 最新のアクティビティを先に表示します
  • 時系列フィードでは、ページ番号ではなく「さらに読み込む」を使ってページ分割します
  • アクター、アクションの種類、リソースの種類、日付範囲で絞り込みます
  • フィールドの変更は差分表示にします:変更前の値 → 変更後の値

11. マルチテナンシーへの配慮

UI でのテナントコンテキスト
  • 現在の組織名またはワークスペース名をサイドバーかヘッダーに常に表示します
  • 組織の切り替えメニューは目立つように配置し、いつでもアクセスできるようにします
  • 組織を切り替えた後は、切り替え先の組織のダッシュボードにリダイレクトします(存在しない可能性がある同じページには移動しません)
データの分離
  • すべての API リクエストにテナントコンテキストを含める必要があります(ヘッダー、パスパラメーター、またはセッション)
  • エラーメッセージ内であっても、他のテナントのデータを表示してはいけません
  • 検索結果は現在のテナントに限定する必要があります
  • 共有可能なリンクでは、URL パスにテナント識別子を含めます:/org/{org-id}/assets
共有リソース

一部のリソースはテナント間で共有されます(請求管理者、スーパー管理者向けのビューなど)。次のように明確に区別してください。

  • テナント単位のビュー:通常のスタイル
  • テナント横断のビュー:異なる背景や管理者バッジなど、見た目を明確に区別

12. アンチパターン

アンチパターンうまくいかない理由より良い方法
初回訪問時にブロッキングモーダルを表示するユーザーはすぐに閉じてしまい、内容を見逃しますインラインチェックリストまたは状況に応じたヒント
空の状態を「データなし」とだけ表示する壊れているように感じられ、案内がありません初回利用向けの空の状態と CTA
読み込みのたびにスピナーを表示するユーザーは遅いと感じますコンテンツの形状に合わせたスケルトンスクリーン
汎用的なアップグレードバナーバナーブラインドネスが起き、ユーザーに無視されます上限に達したときに状況に応じたプロンプトを表示する
設定をフラットなリストにする圧倒され、目的の項目を見つけにくくなります明確な階層でセクションをグループ化する
メニューの中に機能を隠す見つけにくくなります視覚的な手がかりを使った段階的な開示
すべての操作で確認ダイアログを表示するダイアログ疲れを招き、ユーザーは読まずにクリックします元に戻せる操作には取り消しパターンを使う
メールのみで通知するユーザーが見逃し、アプリ内で認識できませんアプリ内通知センターとメールによるフォールバック
無料プランを完全に制限する有料プランへの移行のハードルが高くなります利用量に応じた上限のある、充実した無料プラン
ダウングレード時にデータを即時削除するユーザーがプランの変更をためらいます猶予期間と読み取り専用アクセス
フィルターのないアクティビティフィードアクティブな組織ではノイズに重要な情報が埋もれます実行者、操作、リソース、日付でフィルターする
UI にテナントの表示がないユーザーが誤った組織を変更してしまいます常に現在の組織を表示し、簡単に切り替えられるようにする
SPA 時代:クライアント側でウィザードの状態を管理する更新すると進行状況が失われ、ディープリンクも戻るボタンも使えませんloader/action を通じてサーバー側でステップを管理する
SPA 時代:変更操作に onClick ハンドラーを使うプログレッシブエンハンスメントができず、再検証も行われませんintent パターンを使った <Form method="post">
SPA 時代:クライアント側で機能を制限する制限対象のデータがクライアントに送信され、セキュリティリスクになりますloader でアクセスを確認し、制限対象のデータは決して送信しない

相互参照

  • /ux-design — デザイントークン、アクセシビリティ、コンポーネント API 設計、フォーム UX
  • /react — React コンポーネントのパターン、フック、状態管理
  • /api-design — REST コントラクト、ページネーション、機能の背後にあるエラー形式
  • /domain-design — 集約境界、エンティティと値オブジェクトの違い、ドメインイベント

コメント