关于 expo-design-system
面向 Expo 应用开发的设计系统 Skill,指导建立统一主题令牌与可复用组件,并审查已有系统以避免重复建设。
主要功能
- 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 设计系统
让应用中的每个屏幕都采用同一个视觉事实来源:一套令牌主题和一小组可复用组件。本技能定义了令牌的存放位置、涵盖范围、可复用组件的构造方式,以及何时应将重复视图纳入系统。
相邻的技能负责本技能周边的层:
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或constants/Colors.ts(create-expo-app 默认配置)。 - 如果存在,就以它为事实来源。 按照它自身的惯例进行扩展——使用它的命名、刻度和存储格式。针对该系统检查偏差,而不是对照下方示例。
- 如果只有事实上的值——各屏幕反复使用相同的灰色和内边距,却没有主题文件——则说明还没有系统。这些值是制定刻度的输入,而不是权威标准:从使用频率最高的值中提取令牌,并对齐到 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 保存下方所有对象;当任一类别需要单独文件时,再将其拆分为文件夹结构(组件也遵循相同的拆分规则)。无论采用哪种形式,都只能有一个主题入口——绝不要同时存在两个相互竞争的令牌文件。
让主题物有所值的规则:
- 每个重复出现的视觉值都应是令牌。 同一个字面值出现两次,就应放入主题。
- 组件导入令牌;屏幕导入组件。 屏幕文件为布局内边距导入
spacing没问题;屏幕文件重新定义按钮颜色则属于偏差。 - 绝不要在
src/theme/之外硬编码 十六进制颜色、字号或间距倍数。真正仅用于局部的一次性值(例如图标为了视觉对齐而微调 17px)可以保留为内联值,但须附上说明原因的注释。
颜色
基于平台语义颜色构建调色板:在 Platform.select 中包装来自 expo-router 的 Color,并集中放在 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"];
}
品牌颜色保持精简(强调色、强调色对比色,以及可能的每个功能色调)。其他颜色都使用语义颜色。
静态安全与仅限 Hook 使用。 上述两种模式的适用范围不同——明确区分:
- 语义/平台颜色(上文的
colors)是静态安全的:它们会在设备上解析,因此theme/typography.ts这类普通令牌文件可以在模块作用域导入它们。 - 品牌浅色/深色颜色对是仅限 Hook 使用的:
useBrandColors()会在渲染时读取色彩方案,因此品牌颜色只能在组件内部应用。静态令牌文件不能调用 Hook。 - 切勿在同一个文件中混用两者。如果静态样式(
type字号阶梯中的某一项、variants对象)需要品牌强调色,可以在组件渲染时应用品牌颜色,或用静态动态颜色(iOS 上的DynamicColorIOS)封装颜色对,使其成为静态安全的。
间距
使用一种基于 4 点网格的比例尺。按尺寸命名各级间距,而非按用途命名:
// theme/spacing.ts
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
- 使用间距令牌搭配
gap来控制布局节奏(expo-native-ui更推荐gap,而非 margin)。 - 屏幕边缘内边距使用
spacing.md,除非设计另有要求——选定后保持一致。 - 如果布局需要介于两级之间的值,使用最接近的一级。网格才是关键。
- 如果某个 4 的倍数间距反复出现(12 和 20 很常见),就将其作为命名级别加入比例尺,而不是到处散布字面值。随后也必须将其加入审计白名单。
字体排版
定义命名文本样式,而非使用原始字体大小。参照平台字号阶梯(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>;
如果项目捆绑了静态字体文件(每种字重一个文件,通过 expo-font 或配置插件加载),请改用 fontFamily 名称设置字重,并省略 fontWeight——否则 iOS 会合成该字重,或回退到系统字体:
headline: { fontSize: 17, fontFamily: "SFProRounded-Semibold", color: colors.label },
通过单个组件提供这些样式,确保屏幕不会直接使用 fontSize:
// 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 主要用于非堆栈场景。
动态字体大小。 文本会随用户的系统字体大小设置缩放(默认启用 allowFontScaling)。在文本周围使用内边距或 minHeight,让行高能够扩展,并检查较大的无障碍字体尺寸。优先让标签换行或重新排布,再考虑对受约束的界面元素使用 maxFontSizeMultiplier;仅仅因为行内容密集,并不足以成为限制可读文本缩放的理由。切勿通过 allowFontScaling={false} 在整个应用中禁用缩放。
圆角
// theme/radius.ts
export const radius = {
sm: 8,
md: 12,
lg: 16,
full: 9999, // capsules
} as const;
除胶囊形以外的所有圆角都搭配 borderCurve: "continuous"(遵循 expo-native-ui)。
阴影
阴影使用 boxShadow 字符串(切勿使用旧版 shadow/elevation 属性——请参阅 expo-native-ui)。两到三级的高度层级就足够:
// 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;
动效
统一时长及共享的弹簧/缓动配置,让整个应用中的动画风格保持一致:
// 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 注意事项:不要将 Color/PlatformColor 令牌值传入 Reanimated 样式——应在其中使用静态颜色(请参阅 expo-native-ui)。
可复用组件
主题负责控制取值;组件负责控制结构。共享基础组件位于 src/components/ 中(请参阅 expo-project-structure)。
组件契约
每个设计系统基础组件都明确规定:
- 变体 - 视觉意图:
primary、secondary、ghost、destructive。仅当实际界面需要时才添加变体。 - 尺寸 -
sm、md、lg。默认值为md。尺寸映射到间距/排版令牌,绝不使用新设的数字。 - 状态 - 默认、按下(而非悬停,因为这是触摸操作)、禁用、加载中。使用
Pressable样式函数处理按下状态;可点击元素绝不能缺少按下反馈。 - 样式覆盖 - 接受
style属性,并将其作为最后一项合并,这样调用方就能调整布局(边距、弹性布局),而无需分叉组件。调用方可以覆盖布局,但不能改变组件的视觉身份——如果调用方要修改按钮颜色,就说明变体集合缺少某项。 - 无障碍 - 自定义交互式基础组件应按需公开其角色,以及禁用/忙碌/选中状态。文本子项可以提供标签;仅图标控件,以及用微调指示器替代文本的按钮,都需要显式标签,且该标签在加载期间仍须可用。原生控件上的标签也要核验。
// 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/:
- 它已经出现在(或即将出现在)两个或更多界面中。在此之前,它应留在
screens/<name>/中(参见expo-project-structure)。 - 它有一个可命名的角色(“Card”“EmptyState”“Badge”),而不是“个人资料界面上的那个东西”。
- 它的 API 比实现更精简。如果属性只是重新暴露每个内部样式,那它还不是可复用组件,而是一个界面片段。
提取路径:内联 JSX → screens/<name>/ 中的组件 → src/components/。每次只移动一步,在触发条件出现时再进行——绝不要提前臆测。错误的抽象代价高于代码重复;再复制一份视图,也比设计不佳的基础组件更划算。
不要为了让已有设计语言的系统组件(Switch、DateTimePicker、堆栈标题、@expo/ui 视图)经由设计系统处理而对它们进行封装。这些组件的原生样式本身就是它们的设计系统。
决策归属
| 决策 | 归属位置 | 示例 |
|---|---|---|
| 在任意位置使用两次的视觉值 | src/theme/ | 品牌强调色、间距级别 |
| 可复用元素的结构和变体 | src/components/ | Button、Card、EmptyState |
| 单个界面的私有组合 | screens/<name>/ | 个人资料页标题布局 |
| 一次性局部调整 | 内联,并附上注释 | 微调图标的视觉位置 |
| 界面标题、顶层界面元素 | 导航堆栈选项 | 标题栏标题、大标题 |
自我审查
构建或更改屏幕后,截取屏幕截图,并根据以下原则进行检查(摘自 Expo 的设计原则指南)。每项原则都对应系统级修复,而非局部微调:
- 层级/对比度 - 最重要的元素是否一眼可见、最先映入眼帘?通过
type字号阶梯进行调整,而不是临时设置字体大小。 - 邻近/留白 - 相关项目之间的距离是否比不相关项目更近?通过
gap和间距令牌进行调整。 - 重复/统一 - 所有圆角、阴影和点缀是否一致?如果不一致,说明某个值脱离了主题;将其纳入主题。
- 对齐 - 各边缘是否沿同一轴线对齐?通过一致的屏幕边缘内边距进行调整。
修复某个值后,重新检查渲染结果;将其纳入主题本身并不能修复布局。如果同一问题在多个屏幕中反复出现,请修复共享令牌或组件。还要运行 expo-native-ui「行为」部分中的主要任务和内容检查;仅凭屏幕截图无法验证交互。
常见问题:原生界面粗糙感
构建和审查时,可用以下名称识别常见问题:
- 网页弹窗 - 用于撰写内容或选择项目的自定义居中对话框。优先使用原生底部面板(
formSheet、@expo/uiBottomSheet)或菜单;对于影响重大的操作,原生确认警告框仍然合适。 - 万物皆卡片 - 每一行和每个分区都放在各自白色、圆角且带阴影的框中。使用分组列表;通过背景和细线分组,而不是边框。
- 表情符号图标 - 将 🔥 ⚙️ ✨ 用作标签页或按钮图标。iOS 使用 SF Symbols,Android 使用 Material 图标。
- 紫色渐变主视觉 - 装饰性渐变介绍内容把任务挤到了首屏下方。任务屏幕应以实用内容为主;如果主视觉符合用户要求的体验,则可保留。
- 加载指示器闪现 - 每次状态切换都显示全屏加载指示器,或首次加载时闪现「暂无项目」。每个屏幕都有四种状态(参见
expo-data-fetching)。
将视觉特征视为审查提示,而非对卡片、字体或品牌元素的一概禁令。解决可观察到的问题,并尊重用户的需求和现有设计系统。完整的 20 项列表和可选的 grep 检查见 ./references/native-slop.md;审查屏幕时,请用它们解释问题及替代方案。
审查现有应用
要衡量已有屏幕的应用中存在的偏离情况——硬编码十六进制颜色值、任意间距、不一致的组件 API——请参阅 ./references/audit.md。其中包含基于 grep 的检查、评分标准、修复设计偏离应用的渐进式采用顺序,以及记录现有组件和提议新组件的模板。
提交反馈
如果你发现此技能中有错误、误导性或过时的信息,请报告,以便 Expo 改进:
npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-design-system" "<actionable feedback>"
只有在有具体且可执行的反馈时才提交。请尽可能提供相关背景信息。
如果 Agent 反复无法完成 Expo 任务,或用户不得不介入,请简要报告任务、预期结果和问题所在,并使用 --category evals,同时将相关 Expo 软件包或命令设为 --subject。