汎用デザイン・クリエイティブ⭐ リポ 9品質スコア 80/100
ux-micro-patterns
あらゆるプロダクト状態に対応するUXマイクロパターン:空の状態、ローディング状態(スケルトンスクリーン、スピナー、楽観的UI)、エラー状態、成功状態、確認ダイアログ、オンボーディングフロー、段階的な情報開示が含まれます。これらのパターンはすべての機能に適用でき、実装を誤るとユーザー混乱の最大の原因となります。
description の原文を見る
UX micro-patterns for every product state: Empty States, Loading States (skeleton screens, spinners, optimistic UI), Error States, Success States, Confirmation Dialogs, Onboarding Flows, and Progressive Disclosure. These patterns apply to every feature — done wrong, they're the biggest source of user confusion.
SKILL.md 本文
UX マイクロパターン スキル
活用するタイミング
- 任意の機能がデータを読み込む、変更する、または失敗する可能性がある場合
- 空のリスト状態やデータ0のダッシュボードをデザインする場合
- バリデーションと送信状態を持つフォームを構築する場合
- 破壊的なアクションに確認を追加する場合
- ユーザーを機能にオンボーディングする場合
- スピナーとスケルトンスクリーンのどちらかを決定する場合
パターン 1: 空の状態(Empty States)
空の状態はエラーではなく、ユーザーをガイドする機会です。
悪い空の状態: 「結果が見つかりません。」
良い空の状態: [イラスト] + 見出し + 空の理由 + プライマリアクション
// components/EmptyState.tsx
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
secondaryAction?: { label: string; onClick: () => void };
}
export function EmptyState({ icon, title, description, action, secondaryAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center max-w-sm mx-auto gap-4">
{icon && (
<div className="w-12 h-12 rounded-full bg-surface-overlay flex items-center justify-center text-text-secondary">
{icon}
</div>
)}
<div className="space-y-1">
<h3 className="text-base font-semibold text-text-primary">{title}</h3>
{description && (
<p className="text-sm text-text-secondary">{description}</p>
)}
</div>
{(action || secondaryAction) && (
<div className="flex gap-2">
{action && (
<Button onClick={action.onClick}>{action.label}</Button>
)}
{secondaryAction && (
<Button variant="ghost" onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
)}
</div>
)}
</div>
);
}
// 使用例: 3つの異なるコンテキスト
<EmptyState
icon={<InboxIcon />}
title="No notifications yet"
description="When someone mentions you or comments on your work, it'll show up here."
/>
<EmptyState
icon={<SearchIcon />}
title="No results for “{query}”"
description="Try different keywords or remove filters."
action={{ label: 'Clear filters', onClick: clearFilters }}
/>
<EmptyState
icon={<FolderIcon />}
title="Create your first project"
description="Projects help you organize your work and collaborate with your team."
action={{ label: 'New project', onClick: openCreateModal }}
secondaryAction={{ label: 'Import existing', onClick: openImport }}
/>
空の状態のコンテンツルール:
- タイトル: アクションではなく状態を説明(「Projects」ではなく「No projects yet」)
- 説明: 理由を説明し、何をするべきかを示す
- アクション: 1つの明確なプライマリ CTA — 等しい CTA を2つ用意しない
- イラスト: オプションだが効果的。ジェネリックなストック写真は避ける
パターン 2: ローディング状態(Loading States)
スケルトンスクリーン(スピナーより推奨)
// スケルトン: 読み込み中のコンテンツの形状をミラーリングする
// ルール: 読み込み時間 > 300ms またはコンテンツの構造が既知の場合に使用
function ProjectCardSkeleton() {
return (
<div className="p-4 border border-border rounded-lg space-y-3 animate-pulse">
{/* ヘッダー: アバター + 名前 */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-surface-overlay" />
<div className="h-4 w-32 rounded bg-surface-overlay" />
</div>
{/* ボディ: 2行のテキスト */}
<div className="space-y-2">
<div className="h-3 w-full rounded bg-surface-overlay" />
<div className="h-3 w-3/4 rounded bg-surface-overlay" />
</div>
{/* フッター */}
<div className="h-3 w-24 rounded bg-surface-overlay" />
</div>
);
}
// TanStack Query での使用
function ProjectList() {
const { data: projects, isLoading } = useProjects();
if (isLoading) {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-3 gap-4">
{projects?.map(p => <ProjectCard key={p.id} project={p} />)}
</div>
);
}
スピナー: 使用するタイミング
// スピナーを使用する場合:
// - アクション(ボタン送信、ページ遷移)
// - 短い読み込み時間(300ms 以下の予想)
// - コンテンツの形状が不明な場合
function Spinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClass = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }[size];
return (
<svg
className={`${sizeClass} animate-spin text-current`}
fill="none" viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
楽観的 UI(最速の知覚パフォーマンス)
// サーバー確認前に UI を更新 — エラー時はロールバック
function TodoItem({ todo }: { todo: Todo }) {
const { mutate: toggleTodo } = useMutation({
mutationFn: (id: string) => api.patch(`/todos/${id}/toggle`),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
// チェックボックスを楽観的に反転
queryClient.setQueryData<Todo[]>(['todos'], old =>
old?.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
return { previous };
},
onError: (_, __, context) => {
// サーバーが拒否した場合はロールバック
queryClient.setQueryData(['todos'], context?.previous);
toast.error('Failed to update');
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
return (
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.done ? 'line-through text-text-secondary' : ''}>
{todo.text}
</span>
</label>
);
}
パターン 3: エラー状態(Error States)
// エラーの3つの粒度レベル
// 1. フィールドレベルのエラー(フォーム検証)
<div>
<input aria-invalid={!!error} aria-describedby="email-error" ... />
{error && (
<p id="email-error" className="text-sm text-error mt-1" role="alert">
{error.message}
</p>
)}
</div>
// 2. コンポーネントレベルのエラー(クエリ失敗)
function ProjectList() {
const { data, error, refetch } = useProjects();
if (error) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<AlertCircleIcon className="w-8 h-8 text-error" />
<p className="text-sm text-text-secondary">
Failed to load projects. {error.message}
</p>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
// ...
}
// 3. ページレベルのエラー(ルート/境界)
// Next.js では: error.tsx がルートセグメント内のハンドルされていないエラーをキャッチ
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error, reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-sm text-text-secondary max-w-md text-center">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
);
}
パターン 4: 成功状態(Success States)
// インライン成功(フォーム送信後)
function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const { mutate } = useMutation({ onSuccess: () => setSubmitted(true) });
if (submitted) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<CheckCircleIcon className="w-10 h-10 text-success" />
<h3 className="font-semibold">Message sent!</h3>
<p className="text-sm text-text-secondary">
We'll get back to you within 24 hours.
</p>
</div>
);
}
return <form onSubmit={...}>...</form>;
}
// トースト通知(一時的、ノンブロッキング)
// 使用する場合: 成功したミューテーション、バックグラウンド操作、重要度の低い情報
// 使用しない場合: アクションが必要なエラー、見落とされる可能性がある重要な情報
import { toast } from 'sonner';
onSuccess: () => toast.success('Project created'),
onError: () => toast.error('Failed to create project. Try again.'),
パターン 5: 確認ダイアログ(破壊的なアクション)
// ルール: 取り消しが困難または不可能なアクションの前に確認を表示
// 削除、切断、サブスクリプション解除、データの上書き
function DeleteProjectButton({ projectId, projectName }: Props) {
const [open, setOpen] = useState(false);
const { mutate: deleteProject, isPending } = useDeleteProject();
return (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
Delete project
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete “{projectName}”?</DialogTitle>
<DialogDescription>
This will permanently delete the project and all its data.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="danger"
loading={isPending}
onClick={() => deleteProject(projectId, { onSuccess: () => setOpen(false) })}
>
Delete project
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
確認ダイアログのルール:
- タイトル: 削除する特定の項目を名前付け(「Delete?」ではなく「Delete 'My Project'?」)
- 説明: 何が起こるか + 取り消し不可(「cannot be undone」)
- キャンセル: 常に左、常にデフォルトフォーカス要素
- 確認: 右、危険バリアント、トリガーボタンと同じ正確な文言
- Enter キーでの自動送信は絶対にしない — 意図的なクリックが必須
パターン 6: プログレッシブディスクロージャー
必要な情報のみ表示し、オンデマンドで詳細を表示します。
// 「詳細オプションを表示」— エキスパートコントロールを表示、デフォルト UI をクラッタしない
function CreateProjectForm() {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<form>
{/* 常に表示: コアフィールド */}
<Input label="Project name" name="name" />
<Textarea label="Description" name="description" />
{/* プログレッシブディスクロージャー: 詳細設定 */}
<button
type="button"
className="flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary"
aria-expanded={showAdvanced}
onClick={() => setShowAdvanced(!showAdvanced)}
>
<ChevronIcon className={showAdvanced ? 'rotate-90' : ''} aria-hidden />
Advanced settings
</button>
{showAdvanced && (
<div className="space-y-4 border-l-2 border-border pl-4">
<Select label="Visibility" name="visibility" />
<Input label="Custom domain" name="domain" />
</div>
)}
<Button type="submit">Create project</Button>
</form>
);
}
判断テーブル
| 状況 | パターン |
|---|---|
| リストにまだアイテムがない | プライマリ CTA を持つ EmptyState |
| 検索で何も返されない | 「フィルターをクリア」アクション付き EmptyState |
| コンテンツ読み込み > 300ms | スケルトンスクリーン |
| ボタンアクションが進行中 | スピナー + aria-busy 付きボタン |
| トグル / チェックボックス | 楽観的 UI |
| データ取得失敗 | インラインエラー + 再試行ボタン |
| フォーム送信失敗 | フィールドエラー + トップレベルサマリー |
| ミューテーション成功 | トースト(一時的)またはインライン成功 |
| 破壊的なアクション | 確認ダイアログを先に表示 |
| 複雑なフォーム | 詳細フィールドのプログレッシブディスクロージャー |
チェックリスト
- すべてのリストに空の状態がある(空白だけではない)
- すべての非同期操作にローディング状態がある(スケルトンまたはスピナー)
- すべてのエラーに再試行メカニズムがある
- 成功が認識される(トーストまたはインライン状態の変更)
- 破壊的なアクションが確認ダイアログで保護されている
- 確認ダイアログが影響を受ける特定のアイテムを名前付けしている
- 瞬間的に見えるべき楽観的なアクションに対してスピナーが表示されていない
- 6 個以上のフィールドを持つフォームにプログレッシブディスクロージャーが使用されている
- エラーメッセージが何が起きたか AND何をすべきかを説明している(単なる「Error」ではない)
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- marvinrichter
- リポジトリ
- marvinrichter/clarc
- ライセンス
- MIT
- 最終更新
- 2026/4/27
Source: https://github.com/marvinrichter/clarc / ライセンス: MIT