mcpField
E
expo-design-system

expo-design-system

github.com/expo设计与 UI3.5k17.5k 次安装今天更新
代码仓库

关于 expo-design-system

面向 Expo 应用开发的设计系统 Skill,指导建立统一主题令牌与可复用组件,并审查已有系统以避免重复建设。

主要功能

  1. 01检查并沿用现有设计系统
  2. 02组织颜色、间距与字体令牌
  3. 03定义组件变体、尺寸和状态
  4. 04按需提取可复用组件
  5. 05审查设计系统偏差与反模式

适用场景

  1. 01为新 Expo 应用建立主题结构
  2. 02统一多个屏幕的视觉样式
  3. 03评估现有应用的设计系统漂移

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/expo/skills --skill expo-design-system

安装后在 Claude Code 中输入/expo-design-system确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
references/
audit.md
native-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

开始构建前先采用现有系统

对于已经有屏幕的应用,第一步是检测,而不是构建。在编写任何令牌文件之前:

  1. 查找已声明的系统。 检查 package.json,确认是否使用了样式库,例如 NativeWind/Tailwind、Tamagui、Restyle、Unistyles、styled-components。然后查找令牌文件:theme.ts、src/theme/、constants/theme.ts 或 constants/Colors.ts(create-expo-app 默认配置)。
  2. 如果存在,就以它为事实来源。 按照它自身的惯例进行扩展——使用它的命名、刻度和存储格式。针对该系统检查偏差,而不是对照下方示例。
  3. 如果只有事实上的值——各屏幕反复使用相同的灰色和内边距,却没有主题文件——则说明还没有系统。这些值是制定刻度的输入,而不是权威标准:从使用频率最高的值中提取令牌,并对齐到 4 点网格(references/audit.md §5)。
  4. 绝不要在现有系统旁边再引入第二套系统。 在 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/:

  1. 它已经出现在(或即将出现在)两个或更多界面中。在此之前,它应留在 screens/<name>/ 中(参见 expo-project-structure)。
  2. 它有一个可命名的角色(“Card”“EmptyState”“Badge”),而不是“个人资料界面上的那个东西”。
  3. 它的 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/ui BottomSheet)或菜单;对于影响重大的操作,原生确认警告框仍然合适。
  • 万物皆卡片 - 每一行和每个分区都放在各自白色、圆角且带阴影的框中。使用分组列表;通过背景和细线分组,而不是边框。
  • 表情符号图标 - 将 🔥 ⚙️ ✨ 用作标签页或按钮图标。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。

评论

支持 Markdown