expo-design-system について
Expo アプリ開発向けのデザインシステムスキルです。既存システムの確認を優先し、トークンの整理や再利用可能なコンポーネントの設計を導きます。
主な機能
- 01既存デザインシステムの検出と拡張
- 02色・余白・文字のトークン整理
- 03コンポーネントの種類・サイズ・状態の定義
- 04適切なタイミングでの再利用部品の抽出
- 05デザインシステムのずれやアンチパターンの監査
活用シーン
- 01新しい Expo アプリのテーマ設計
- 02画面間のビジュアルスタイル統一
- 03アプリのデザインシステムのずれの確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ expo/ skills --skill expo-design-systemインストール後、Claude Code で/expo-design-systemを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamlreferences/audit.mdnative-slop.md詳細
Expo Design Systems
アプリのすべての画面で、トークンテーマと少数の再利用可能なコンポーネントから成る、単一の視覚的な基準を参照できるようにします。このスキルでは、トークンの配置場所と対象範囲、再利用可能なコンポーネントの構成、繰り返し使われるビューをデザインシステムに昇格させるタイミングを定義します。
このスキルと連携するスキルでは、その周辺レイヤーを扱います。
expo-native-ui- プラットフォームのスタイリング規則(HIG、セマンティックカラー、コントロール、シャドウの構文)。どの値がネイティブらしいかはこのスキルに従い、値をどこに置き、どう再利用するかはこのスキルに従います。expo-project-structure- 新しいアプリのフォルダ構成。
Tailwind プロジェクトでは、トークンを CSS 変数として global.css に置き、スタイリングライブラリ独自のセットアップガイドに従います。このスキルで定めるスケールと命名規則は引き続き適用されますが、保存形式だけが変わります。
参考資料
必要に応じて、以下のリソースを参照してください。
references/
audit.md Audit an existing app for design-system drift: grep checks,
scoring rubric, incremental adoption plan, and templates for
documenting or extending components
native-slop.md The 20 named anti-pattern tells of AI-generated apps (The Web
Modal, Everything's a Card, ...) with grep checks for the greppable ones
構築前に既存のものを採用する
すでに画面があるアプリでは、最初に行うのは構築ではなく検出です。トークンファイルを書く前に、次の手順を実行してください。
- 宣言済みのシステムを探します。
package.jsonでスタイリングライブラリ(NativeWind/Tailwind、Tamagui、Restyle、Unistyles、styled-components)を確認します。次に、トークンファイル(theme.ts、src/theme/、constants/theme.ts、または create-expo-app のデフォルトであるconstants/Colors.ts)を探します。 - 既存のものがあれば、それが信頼できる唯一の情報源です。 その命名規則、スケール、保存形式に合わせて拡張します。以下の例ではなく、そのシステムを基準にして差異を監査してください。
- 慣例的な値だけが存在する場合(画面間で同じグレーや余白が繰り返されているものの、テーマファイルがない場合)は、まだシステムがありません。これらの値はスケールの入力であり、基準ではありません。最も頻繁に使われる値を基に、4ポイントグリッドに合わせてトークンを導き出します(
references/audit.md§5)。 - 既存のシステムの隣に、別のシステムを決して導入しないでください。 Tamagui の設定の隣に新しい
src/theme/を作るのは、採用ではなくデザインシステムの乖離です。
何も存在しない場合に限り、以下のデフォルトをそのまま適用します。
テーマ
既存のシステムがないアプリでは、すべてのデザイントークンを src/theme/ に置きます。src/ フォルダがないプロジェクト(デフォルトの create-expo-app テンプレートでは、ルートに app/、components/、constants/ があります)では、同等のトップレベルの場所(通常は theme/ または既存の constants/)を使い、同じファイル構成を維持します。まずは小さく始め、成長に応じてトークンの種類ごとに分割します。
src/theme/
colors.ts # see expo-native-ui "Colors" for the palette pattern
spacing.ts
typography.ts
radius.ts
shadows.ts
motion.ts
index.ts # re-exports everything: import { spacing, type } from "@/theme"
新しいアプリでは、まず下記のすべてのオブジェクトを含む単一の src/theme.ts から始め、いずれかの種類で個別のファイルが必要になった時点で、フォルダ形式に昇格できます(コンポーネントと同じ昇格ルールです)。どちらの場合も、テーマのエントリーポイントは1つだけです。競合するトークンファイルを2つ作ってはいけません。
テーマを設ける価値をもたらすルール:
- 繰り返し使われる視覚的な値はすべてトークンです。 2回登場するリテラル値は、テーマに含めます。
- コンポーネントはトークンをインポートし、画面はコンポーネントをインポートします。 レイアウトの余白に
spacingをインポートする画面ファイルは問題ありませんが、ボタンの色を再定義する画面ファイルは乖離です。 src/theme/の外で、16進カラー、フォントサイズ、または余白の倍数をハードコードしないでください。アイコンの光学的な位置調整に使う 17px など、本当にローカルな一度限りの値は、理由をコメントに記載すればインラインで指定してもかまいません。
Colors
プラットフォームのセマンティックカラーを基にパレットを構築します。expo-router の Color を Platform.select でラップし、theme/colors.ts に集約します。セマンティックカラーはデバイス上で解決され、ライト/ダークに自動で適応するため、背景、ラベル、区切り線には優先して使用してください。(expo-native-ui の「Colors」では、パレット全体とその理由を説明しています。最小限の例は次のとおりです。)
// theme/colors.ts
import { Platform } from "react-native";
import { Color } from "expo-router";
export const colors = {
label: Platform.select({
ios: Color.ios.label,
android: Color.android.dynamic.onSurface,
default: "#000000",
})!,
secondaryLabel: Platform.select({
ios: Color.ios.secondaryLabel,
android: Color.android.dynamic.onSurfaceVariant,
default: "#3c3c43",
})!,
separator: Platform.select({
ios: Color.ios.separator,
android: Color.android.dynamic.outlineVariant,
default: "#c6c6c8",
})!,
systemBackground: Platform.select({
ios: Color.ios.systemBackground,
android: Color.android.dynamic.surface,
default: "#ffffff",
})!,
systemBlue: Platform.select({
ios: Color.ios.systemBlue,
android: Color.android.dynamic.primary,
default: "#007aff",
})!,
// Deliberately fixed: text on a tinted (accent) surface stays white in both modes.
onTint: "#ffffff",
};
ブランドカラーは、プラットフォームにない値が必要な場合に限り、ライト/ダークのペアを明示的に追加します。
// theme/colors.ts (brand additions)
import { useColorScheme } from "react-native";
const brandPalette = {
light: { accent: "#5B21B6", accentContrast: "#FFFFFF" },
dark: { accent: "#A78BFA", accentContrast: "#1E1B4B" },
} as const;
export function useBrandColors() {
const scheme = useColorScheme();
return brandPalette[scheme === "dark" ? "dark" : "light"];
}
ブランドカラーは最小限にとどめます(アクセント、アクセントのコントラストカラー、必要であれば機能ごとのティントカラーなど)。それ以外はすべてセマンティックカラーを使用します。
静的に安全なカラーとフック専用カラー。 上記の2つのパターンは使用できる範囲が異なるため、境界を明確にしてください。
- セマンティックカラー/プラットフォームカラー(上記の
colors)は静的に安全です。デバイス上で解決されるため、theme/typography.tsのような通常のトークンファイルからモジュールスコープでインポートできます。 - ブランドカラーのライト/ダークのペアはフック専用です。
useBrandColors()はレンダー時にカラースキームを読み取るため、ブランドカラーはコンポーネント内でのみ適用できます。静的なトークンファイルからフックを呼び出すことはできません。 - 1つのファイルで両者を混在させないでください。静的スタイル(
typeのランプのステップやvariantsオブジェクトなど)でブランドのアクセントカラーが必要な場合は、レンダー時にコンポーネントで適用するか、ペアを静的な動的カラー(iOS ではDynamicColorIOS)でラップして、静的に安全な状態にします。
Spacing
4ポイントグリッドに基づく、1つのスケールを使用します。用途ではなく、サイズでステップに名前を付けます。
// theme/spacing.ts
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
- レイアウトのリズムには、スペーシングトークンと
gapを使用します(expo-native-uiでは margin より gap が推奨されています)。 - デザインで別途指定されていない限り、画面端のパディングには
spacing.mdを使用します。値を1つ選び、統一してください。 - レイアウトでステップ間の値が必要になった場合は、最も近いステップを使用します。グリッドを設けることが重要です。
- 4の倍数で、ステップ間の値が繰り返し必要になる場合(12と20がよくあります)は、リテラル値を点在させるのではなく、名前付きのステップとしてスケールに追加します。その場合は監査の許可リストにも追加してください。
Typography
フォントサイズを直接指定せず、名前付きのテキストスタイルを定義します。ネイティブな見た目になるよう、プラットフォームのサイズランプ(Apple のテキストスタイル)に合わせます。
// theme/typography.ts
import { TextStyle } from "react-native";
import { colors } from "./colors";
export const type = {
largeTitle: { fontSize: 34, fontWeight: "700", color: colors.label },
title: { fontSize: 22, fontWeight: "600", color: colors.label },
headline: { fontSize: 17, fontWeight: "600", color: colors.label },
body: { fontSize: 17, fontWeight: "400", color: colors.label },
subhead: { fontSize: 15, fontWeight: "400", color: colors.secondaryLabel },
caption: { fontSize: 12, fontWeight: "400", color: colors.secondaryLabel },
} as const satisfies Record<string, TextStyle>;
プロジェクトに静的フォントファイルが含まれている場合(ウェイトごとに1ファイルを用意し、expo-font または設定プラグインで読み込む場合)は、代わりに fontFamily の名前でウェイトを指定し、fontWeight は省略します。そうしないと、iOS でウェイトが合成されるか、システムフォントにフォールバックします。
headline: { fontSize: 17, fontFamily: "SFProRounded-Semibold", color: colors.label },
画面から fontSize に直接触れずに済むよう、1つのコンポーネントを通じて公開します。
// components/themed-text.tsx
import { Text, TextProps } from "react-native";
import { type } from "@/theme";
export function ThemedText({
variant = "body",
style,
...props
}: TextProps & { variant?: keyof typeof type }) {
return <Text style={[type[variant], style]} {...props} />;
}
画面タイトルは引き続きナビゲーションスタックのヘッダーから取得されるため(expo-native-ui のルール)、largeTitle は主にスタック外のコンテキストで使用します。
Dynamic Type。 テキストは、ユーザーのシステムの文字サイズ設定に応じて拡大縮小します(デフォルトで allowFontScaling は有効です)。テキストの周囲にはパディングまたは minHeight を使い、行が大きくなれるようにして、大きなアクセシビリティ文字サイズも確認してください。制約のあるクロームで要素ごとの maxFontSizeMultiplier を検討する前に、ラベルを折り返すか、レイアウトを再配置してください。行の情報量が多いことだけを理由に、読みやすいテキストの拡大を制限しないでください。アプリ全体で allowFontScaling={false} を使って拡大縮小を無効にしないでください。
Radius
// theme/radius.ts
export const radius = {
sm: 8,
md: 12,
lg: 16,
full: 9999, // capsules
} as const;
カプセル形状以外のすべての角丸には、borderCurve: "continuous" を組み合わせます(expo-native-ui に準拠)。
Shadows
シャドウは boxShadow 文字列で指定します(従来の shadow/elevation プロパティは使用しません。expo-native-ui を参照)。高さのレベルは2、3段階で十分です。
// theme/shadows.ts
export const shadows = {
card: "0 1px 2px rgba(0, 0, 0, 0.05)",
raised: "0 4px 12px rgba(0, 0, 0, 0.10)",
overlay: "0 8px 24px rgba(0, 0, 0, 0.18)",
} as const;
Motion
アプリ全体のアニメーションに統一感を持たせるため、時間と共通のスプリング/イージング設定を定義します。
// theme/motion.ts
export const motion = {
fast: 150, // state feedback: press, toggle
base: 250, // element transitions: enter/exit
slow: 400, // large surfaces: sheets, screens
} as const;
Reanimated に関する注意:Reanimated のスタイルには Color/PlatformColor のトークン値を渡さず、静的カラーを使用してください(expo-native-ui を参照)。
再利用可能なコンポーネント
テーマは値を管理し、コンポーネントは構造を管理します。共有プリミティブは src/components/ に配置します(expo-project-structure を参照)。
コンポーネントの契約
すべてのデザインシステムのプリミティブでは、次の項目を明示的に定義します。
- バリアント - 視覚的な意図:
primary、secondary、ghost、destructive。実際の画面で必要な場合にのみ、バリアントを追加します。 - サイズ -
sm、md、lg。デフォルトはmdです。サイズはスペーシング/タイポグラフィのトークンに対応させ、新たな数値を設定しません。 - 状態 - デフォルト、押下中(ホバーではありません。これはタッチ操作です)、無効、読み込み中。押下中の状態は
Pressableのスタイル関数で処理します。タップ可能な要素に押下時のフィードバックを設定しないままにしてはいけません。 - スタイルの上書き -
styleプロパティを受け取り、最後にマージします。これにより、呼び出し元はコンポーネントをフォークせずにレイアウト(マージン、flex)を調整できます。呼び出し元が上書きできるのはレイアウトであり、コンポーネントの性質ではありません。呼び出し元がボタンの色を変更しているなら、それはバリアントの種類が不足しているサインです。 - アクセシビリティ - カスタムのインタラクティブなプリミティブでは、該当する場合、ロールと無効/ビジー/選択状態を公開します。テキストの子要素をラベルとして使えます。アイコンのみのコントロールや、テキストをスピナーに置き換えるボタンでは、読み込み中も利用可能な明示的なラベルが必要です。ネイティブコントロールのラベルも確認してください。
// components/button.tsx
import { Pressable, ActivityIndicator, ViewStyle, StyleProp } from "react-native";
import { colors, spacing, radius } from "@/theme";
import { ThemedText } from "./themed-text";
const variants = {
primary: { backgroundColor: colors.systemBlue, color: colors.onTint },
secondary: { backgroundColor: colors.separator, color: colors.label },
} as const;
const sizes = {
sm: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm },
md: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md },
} as const;
export function Button({
variant = "primary",
size = "md",
title,
loading,
disabled,
style,
onPress,
}: {
variant?: keyof typeof variants;
size?: keyof typeof sizes;
title: string;
loading?: boolean;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
onPress?: () => void;
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={title}
accessibilityState={{ disabled: !!(disabled || loading), busy: !!loading }}
disabled={disabled || loading}
onPress={onPress}
style={({ pressed }) => [
{
backgroundColor: variants[variant].backgroundColor,
borderRadius: radius.md,
borderCurve: "continuous",
alignItems: "center",
opacity: disabled ? 0.4 : pressed ? 0.7 : 1,
...sizes[size],
},
style, // caller overrides merge last
]}
>
{loading ? (
<ActivityIndicator color={variants[variant].color as string} />
) : (
<ThemedText variant="headline" style={{ color: variants[variant].color }}>
{title}
</ThemedText>
)}
</Pressable>
);
}
設定よりコンポジションを優先
コンポーネントのプロパティがコンテンツ(leftIcon、subtitle、footerText、badgeCount)を表し始めたら、プロパティを増やすのをやめて、代わりにchildrenを受け取るようにします。トークンに基づくパディングでchildrenを描画するCardは、コンテンツ用のプロパティを12個持つCardより長く使えます。プロパティは、上記の契約にあるバリアント、サイズ、状態、スタイルのために使います。
抽出する場合、しない場合
次のすべてに当てはまる場合に、ビューをsrc/components/へ昇格させます。
- 2つ以上の画面に表示される(または表示される予定である)。それまでは
screens/<name>/に置いたままにします(expo-project-structureを参照)。 - 名前で役割を説明できる(「Card」「EmptyState」「Badge」など)。「プロフィール画面にあるあれ」ではありません。
- APIが実装よりも小さい。プロパティが内部スタイルをすべて公開するだけなら、まだ再利用可能なコンポーネントではなく、画面の断片です。
昇格の流れ:インラインJSX → screens/<name>/内のコンポーネント → src/components/。きっかけが発生したときに、一度に1段階ずつ移動します。先を見越して移動してはいけません。誤った抽象化は重複よりもコストがかかります。ビューを2つ作るほうが、APIの悪いプリミティブを作るよりも低コストです。
デザインの特徴をすでに備えているプラットフォームコンポーネント(Switch、DateTimePicker、スタックヘッダー、@expo/uiのビュー)を、システム経由で扱うためだけにラップしないでください。それらにとって、ネイティブのスタイルこそがデザインシステムです。
決定事項の置き場所
| 決定事項 | 置き場所 | 例 |
|---|---|---|
| 2か所以上で使う視覚的な値 | src/theme/ | ブランドのアクセント、スペーシングの段階 |
| 再利用する要素の構造とバリアント | src/components/ | Button、Card、EmptyState |
| 1つの画面だけで使うプライベートなコンポジション | screens/<name>/ | プロフィールのヘッダーレイアウト |
| 1回限りのローカルな調整 | インライン(コメントを添える) | アイコンの見た目を整える微調整 |
| 画面タイトル、最上位のクローム | ナビゲーションスタックのオプション | ヘッダータイトル、大きなタイトル |
セルフレビュー
画面を作成または変更した後、スクリーンショットを撮り、以下の原則に照らして確認します(Expo のデザイン原則ガイドより)。それぞれ、個別の調整ではなくシステム側の修正につながります。
- 階層/コントラスト - 最も重要な要素が明らかに最初に目に入りますか?場当たり的にフォントサイズを変えるのではなく、
typeの段階を調整して修正します。 - 近接性/余白 - 関連する項目同士は、関連しない項目同士より近くに配置されていますか?
gapとスペーシングトークンを使って修正します。 - 反復/統一感 - すべての角、影、アクセントに一貫性がありますか?そうでなければ、テーマから値が漏れているため、テーマに移します。
- 整列 - 各辺は同じ軸に揃っていますか?画面端のパディングを統一して修正します。
値を修正した後は、レンダリング結果を再確認してください。値をテーマに移すだけでは、レイアウトは修正されません。同じ不具合が複数の画面で再発する場合は、共有トークンまたはコンポーネントを修正します。また、expo-native-ui の Behavior セクションにある主要タスクとコンテンツのチェックも実行してください。スクリーンショットだけでは操作を確認できません。
よくある失敗:ネイティブらしさの欠如
画面の作成やレビューでよくある間違いに気づくため、以下の名称を使います。
- ウェブ風モーダル - 入力や選択に使う、中央に配置したカスタムダイアログです。ネイティブのシート(
formSheet、@expo/uiBottomSheet)またはメニューを優先してください。重要な操作には、ネイティブの確認アラートが適しています。 - 何でもカード - すべての行やセクションを、それぞれ白い角丸の影付きボックスに入れることです。グループ化されたリストを使い、境界線ではなく背景色と細い区切り線でグループ化してください。
- 絵文字アイコン - タブやボタンのアイコンに 🔥 ⚙️ ✨ を使うことです。iOS では SF Symbols、Android では Material icons を使ってください。
- 紫のグラデーションヒーロー - タスクを画面下に押しやる、装飾的なグラデーションの導入画面です。タスク画面では役立つコンテンツを先に表示してください。求められる体験に役立つ場合は、ヒーローを残してください。
- スピナーの点滅 - 状態が変わるたびに全画面スピナーを表示したり、初回読み込み中に「項目はまだありません」と一瞬表示したりすることです。すべての画面には 4 つの状態があります(
expo-data-fetchingを参照)。
こうした視覚的な特徴は、レビューのきっかけとして扱い、カード、フォント、ブランディングを一律に禁止するものではありません。目に見える問題を修正し、ユーザーの依頼内容と既存のデザインシステムを尊重してください。20 項目の全リストと grep のチェック候補は ./references/native-slop.md にあります。画面をレビューする際は、問題と代替案の説明に活用してください。
既存アプリの監査
すでに画面があるアプリで、ハードコードされた 16 進数値、任意のスペーシング、一貫性のないコンポーネント API などのずれを測定するには、./references/audit.md に従ってください。grep ベースのチェック、評価基準、ずれたアプリを修正するための段階的な導入順序、既存コンポーネントを文書化して新しいコンポーネントを提案するためのテンプレートが記載されています。
フィードバックの送信
このスキルにエラー、誤解を招く情報、または古い情報を見つけた場合は、Expo が改善できるよう報告してください。
npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-design-system" "<actionable feedback>"
具体的で実行可能な報告内容がある場合に限り、送信してください。関連するコンテキストをできるだけ詳しく含めてください。
エージェントが Expo のタスクで繰り返し失敗する場合や、ユーザーが介入する必要がある場合は、タスクの概要、期待される結果、問題の内容を簡潔に報告し、--category evals と、関連する Expo パッケージまたはコマンドを --subject として指定してください。