expo-design-system

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Expo Design Systems

Expo 设计系统

Make every screen in an app draw from one visual source of truth: a token theme and a small set of reusable components. This skill defines where tokens live, what they cover, how reusable components are shaped, and when a repeated view earns promotion into the system.
Sibling skills own the layers around this one:
  • expo-native-ui
    - platform styling rules (HIG, semantic colors, controls, shadows syntax). Follow it for what values look native; follow this skill for where values live and how they're reused.
  • expo-tailwind-setup
    - if the project uses Tailwind, tokens live in
    global.css
    as CSS variables instead of TypeScript. The scales and naming in this skill still apply; only the storage format changes.
  • expo-project-structure
    - folder skeleton for new apps.
让应用中的每一个界面都遵循统一的视觉单一数据源:令牌主题与少量可复用组件。本技能定义了令牌的存放位置、覆盖范围、可复用组件的结构,以及何时将重复视图升级到系统中。
相关的同级技能负责本技能之外的层面:
  • expo-native-ui
    - 平台样式规则(HIG、语义化颜色、控件、阴影语法)。遵循该技能了解原生样式的取值;遵循本技能了解值的存放位置与复用方式
  • expo-tailwind-setup
    - 如果项目使用Tailwind,令牌将以CSS变量形式存放在
    global.css
    中,而非TypeScript文件。本技能中的刻度与命名规则仍然适用,仅存储格式有所变化。
  • expo-project-structure
    - 新应用的文件夹骨架。

References

参考资料

Consult these resources as needed:
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
按需查阅以下资源:
references/
  audit.md      检查现有应用是否存在设计系统偏差:grep检查、评分标准、渐进式采用方案,以及用于文档化或扩展组件的模板

Adopt Before You Build

构建前先采用

In an app that already has screens, the first move is detection, not construction. Before writing any token file:
  1. Look for a declared system. Check
    package.json
    for a styling library - NativeWind/Tailwind (use
    expo-tailwind-setup
    ), Tamagui, Restyle, Unistyles, styled-components. Then look for a token file:
    theme.ts
    ,
    src/theme/
    ,
    constants/theme.ts
    , or
    constants/Colors.ts
    (the create-expo-app default).
  2. If one exists, it is the source of truth. Extend it in its own idiom - its names, its scale, its storage format. Audit drift against that system, not against the examples below.
  3. If only de facto values exist - the same greys and paddings repeated across screens, no theme file - there is no system yet. Those values are the input to the scales, not the authority: derive tokens from the most frequent ones, snapped to the 4-point grid (
    references/audit.md
    §5).
  4. Never introduce a second system beside an existing one. A fresh
    src/theme/
    next to a Tamagui config is design-system drift, not adoption.
Only when nothing exists do the defaults below apply as written.
对于已有界面的应用,第一步是检测而非构建。在编写任何令牌文件前:
  1. 查找已声明的系统。检查
    package.json
    中的样式库——NativeWind/Tailwind(请使用
    expo-tailwind-setup
    )、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/
    属于设计系统偏差,而非采用。
只有当完全没有现有系统时,以下默认规则才完全适用。

The Theme

主题

In an app without an existing system, all design tokens live under
src/theme/
. In a project without a
src/
folder (the default
create-expo-app
template has
app/
,
components/
, and
constants/
at the root), use the equivalent top-level location - typically
theme/
or the existing
constants/
- and keep the same file layout. Start small and split by token class as it grows:
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"
A brand-new app can begin with a single
src/theme.ts
holding all of the objects below, then promote it to the folder form once any one class needs its own file (same promotion rule as components). Either way there is exactly one theme entry point - never two competing token files.
Rules that make a theme worth having:
  • Every repeated visual value is a token. A literal that appears twice belongs in the theme.
  • Components import tokens; screens import components. A screen file that imports
    spacing
    for layout padding is fine; a screen file redefining a button color is drift.
  • Never hardcode hex colors, font sizes, or spacing multiples outside
    src/theme/
    . One-off values that are genuinely local (an icon's 17px optical nudge) may stay inline - with a comment saying why.
对于没有现有系统的应用,所有设计令牌都存放在
src/theme/
目录下。对于没有
src/
目录的项目(默认create-expo-app模板在根目录下有
app/
components/
constants/
),请使用对应的顶层位置——通常是
theme/
或已有的
constants/
——并保持相同的文件布局。从最小规模开始,当某类令牌需要单独文件时再拆分:
src/theme/
  colors.ts       # 请参考expo-native-ui的「Colors」部分了解调色板模式
  spacing.ts
  typography.ts
  radius.ts
  shadows.ts
  motion.ts
  index.ts        # 导出所有内容:import { spacing, type } from "@/theme"
全新应用可以从单个包含所有对象的
src/theme.ts
开始,当任何一类令牌需要单独文件时再升级为文件夹形式(与组件的升级规则相同)。无论哪种方式,都只有一个主题入口——绝不要有两个相互竞争的令牌文件。
让主题具备价值的规则:
  • 每一个重复的视觉值都是令牌。出现两次的字面量应放入主题中。
  • 组件导入令牌;界面导入组件。界面文件导入
    spacing
    用于布局内边距是可行的;但界面文件重新定义按钮颜色则属于偏差。
  • 绝不要硬编码十六进制颜色、字体大小或间距倍数到
    src/theme/
    之外。真正属于本地的一次性值(如图标17px的视觉微调)可以保留在代码中——但需添加注释说明原因。

Colors

颜色

Build the palette from platform semantic colors:
Color
from
expo-router
wrapped in
Platform.select
, centralized in
theme/colors.ts
. Semantic colors resolve on-device and adapt to light/dark automatically - prefer them for backgrounds, labels, and separators. (
expo-native-ui
"Colors" covers the full palette and rationale; the minimal version is:)
tsx
// 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",
};
Add brand colors as explicit light/dark pairs only when the brand requires values the platform doesn't provide:
tsx
// 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"];
}
Keep the brand set tiny (accent, accentContrast, maybe a tint per feature). Everything else stays semantic.
Static-safe vs hook-only. The two patterns above have different reach - keep the boundary explicit:
  • Semantic/platform colors (
    colors
    above) are static-safe: they resolve on-device, so plain token files like
    theme/typography.ts
    can import them at module scope.
  • Brand light/dark pairs are hook-only:
    useBrandColors()
    reads the color scheme at render time, so brand colors can only be applied inside components. A static token file cannot call the hook.
  • Never mix the two in one file. If a static style (a
    type
    ramp step, a
    variants
    object) needs the brand accent, either apply the brand color in the component at render time, or wrap the pair in a static dynamic color (
    DynamicColorIOS
    on iOS) so it becomes static-safe.
基于平台语义化颜色构建调色板:使用
expo-router
中的
Color
并包裹在
Platform.select
中,集中存放在
theme/colors.ts
中。语义化颜色会在设备上解析,并自动适配明暗模式——优先将其用于背景、标签和分隔线。(
expo-native-ui
的「Colors」部分涵盖完整调色板及原理;最简版本如下:)
tsx
// 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",
  })!,
  // 固定值:在着色(强调)表面上的文本在两种模式下均保持白色。
  onTint: "#ffffff",
};
仅当品牌需要平台未提供的值时,才添加显式的明暗模式品牌颜色对:
tsx
// theme/colors.ts(品牌扩展)
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"];
}
保持品牌颜色集精简(强调色、强调色对比色,可为每个功能添加一个色调)。其他所有颜色均使用语义化颜色。
静态安全 vs 仅钩子。上述两种模式的适用范围不同——请明确区分边界:
  • 语义化/平台颜色(上述
    colors
    )是静态安全的:它们在设备上解析,因此像
    theme/typography.ts
    这样的纯令牌文件可以在模块作用域导入它们。
  • 品牌明暗颜色对是仅钩子可用的
    useBrandColors()
    在渲染时读取颜色模式,因此品牌颜色只能在组件内部应用。静态令牌文件无法调用该钩子。
  • 绝不要在一个文件中混合两种模式。如果静态样式(如
    type
    刻度步骤、
    variants
    对象)需要品牌强调色,请在组件渲染时应用品牌颜色,或者将颜色对包装为静态动态颜色(iOS上的
    DynamicColorIOS
    )使其变为静态安全。

Spacing

间距

One scale, based on a 4-point grid. Name steps by size, not by use:
tsx
// theme/spacing.ts
export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  xxl: 48,
} as const;
  • Use
    gap
    with spacing tokens for layout rhythm (
    expo-native-ui
    prefers gap over margin).
  • Screen edge padding is
    spacing.md
    unless the design says otherwise - pick one and keep it.
  • If a layout needs a value between steps, use the nearest step. The grid is the point.
  • If the same in-between multiple of 4 keeps recurring (12 and 20 are common), add it to the scale as a named step instead of scattering literals. The audit whitelist must then include it too.
单一刻度,基于4点网格。按大小而非用途命名步骤:
tsx
// 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很常见)反复出现,请将其作为命名步骤添加到刻度中,而非分散使用字面量。审计白名单也必须包含该值。

Typography

排版

Define named text styles, not raw font sizes. Mirror the platform ramp (Apple text styles) so sizes feel native:
tsx
// 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>;
If the project bundles static font files (one file per weight, loaded with
expo-font
or the config plugin), set weight via
fontFamily
names instead and omit
fontWeight
- otherwise iOS synthesizes the weight or falls back to the system font:
tsx
headline: { fontSize: 17, fontFamily: "SFProRounded-Semibold", color: colors.label },
Expose them through one component so screens never touch
fontSize
:
tsx
// 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} />;
}
Screen titles still come from the navigation stack header (
expo-native-ui
rule), so
largeTitle
is mostly for non-stack contexts.
定义命名文本样式,而非原始字体大小。镜像平台刻度(Apple文本样式)使尺寸更贴近原生:
tsx
// 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会合成字重或回退到系统字体:
tsx
headline: { fontSize: 17, fontFamily: "SFProRounded-Semibold", color: colors.label },
通过一个组件暴露这些样式,使界面永远无需直接操作
fontSize
tsx
// 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
主要用于非栈上下文。

Radius

圆角

tsx
// theme/radius.ts
export const radius = {
  sm: 8,
  md: 12,
  lg: 16,
  full: 9999, // capsules
} as const;
Pair every non-capsule radius with
borderCurve: "continuous"
(per
expo-native-ui
).
tsx
// theme/radius.ts
export const radius = {
  sm: 8,
  md: 12,
  lg: 16,
  full: 9999, // 胶囊状
} as const;
每个非胶囊状圆角都要搭配
borderCurve: "continuous"
(遵循
expo-native-ui
规则)。

Shadows

阴影

Shadows are
boxShadow
strings (never legacy shadow/elevation props - see
expo-native-ui
). Two or three elevation levels are enough:
tsx
// 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;
阴影使用
boxShadow
字符串(绝不要使用旧版shadow/elevation属性——见
expo-native-ui
)。2到3个层级足够:
tsx
// 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

动效

Durations and shared spring/easing configs, so animations across the app feel related:
tsx
// 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 caveat: don't pass
Color
/
PlatformColor
token values into Reanimated styles - use static colors there (see
expo-native-ui
).
时长与共享的弹簧/缓动配置,使应用中的动画保持风格一致:
tsx
// theme/motion.ts
export const motion = {
  fast: 150, // 状态反馈:按压、切换
  base: 250, // 元素过渡:进入/退出
  slow: 400, // 大型表面:弹窗、界面
} as const;
Reanimated注意事项:不要将
Color
/
PlatformColor
令牌值传入Reanimated样式——请在其中使用静态颜色(见
expo-native-ui
)。

Reusable Components

可复用组件

The theme controls values; components control structure. Shared primitives live in
src/components/
(see
expo-project-structure
).
主题控制取值;组件控制结构。共享原语存放在
src/components/
目录下(见
expo-project-structure
)。

The component contract

组件契约

Every design-system primitive defines, explicitly:
  • Variants - visual intent:
    primary
    ,
    secondary
    ,
    ghost
    ,
    destructive
    . Add a variant only when a real screen needs it.
  • Sizes -
    sm
    ,
    md
    ,
    lg
    . Default
    md
    . Sizes map to spacing/typography tokens, never to fresh numbers.
  • States - default, pressed (not hover - this is touch), disabled, loading. Handle pressed with a
    Pressable
    style function; never leave a tappable element without pressed feedback.
  • Style override - accept a
    style
    prop and merge it last, so callers can adjust layout (margins, flex) without forking the component. Callers may override layout, not identity - a caller changing a button's colors is a signal the variant set is missing something.
tsx
// 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"
      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>
  );
}
每个设计系统原语都明确定义:
  • 变体 - 视觉意图:
    primary
    secondary
    ghost
    destructive
    。仅当真实界面需要时才添加变体。
  • 尺寸 -
    sm
    md
    lg
    。默认
    md
    。尺寸映射到间距/排版令牌,而非新的数值。
  • 状态 - 默认、按压(不是悬停——这是触摸操作)、禁用、加载。使用
    Pressable
    样式函数处理按压状态;绝不要让可点击元素没有按压反馈。
  • 样式覆盖 - 接受
    style
    属性并最后合并,使调用者可以调整布局(外边距、flex)而无需分叉组件。调用者可覆盖布局,但不能修改组件本质——如果调用者修改按钮颜色,说明变体集缺失了某些内容。
tsx
// 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"
      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, // 调用者的覆盖样式最后合并
      ]}
    >
      {loading ? (
        <ActivityIndicator color={variants[variant].color as string} />
      ) : (
        <ThemedText variant="headline" style={{ color: variants[variant].color }}>
          {title}
        </ThemedText>
      )}
    </Pressable>
  );
}

Composition over configuration

组合优先于配置

When a component's props start describing content (
leftIcon
,
subtitle
,
footerText
,
badgeCount
), stop adding props and accept
children
instead. A
Card
that renders
children
with token padding outlives any
Card
with twelve content props. Reserve props for the contract above: variant, size, state, style.
当组件的属性开始描述内容
leftIcon
subtitle
footerText
badgeCount
)时,停止添加属性并改为接受
children
。一个使用令牌内边距渲染
children
Card
组件,比带有12个内容属性的
Card
组件更耐用。仅为上述契约保留属性:变体、尺寸、状态、样式。

When to extract - and when not to

何时提取——何时不提取

Promote a view into
src/components/
when all of these hold:
  1. It appears (or is about to appear) in two or more screens. Until then it stays colocated in
    screens/<name>/
    (see
    expo-project-structure
    ).
  2. It has a nameable role ("Card", "EmptyState", "Badge") - not "the thing on the profile screen".
  3. Its API is smaller than its implementation. If the props would just re-expose every internal style, it isn't a reusable component yet - it's a screen fragment.
Promotion path: inline JSX → component in
screens/<name>/
src/components/
. Move one step at a time, when the trigger fires - never speculatively. Wrong abstractions cost more than duplication; a second copy of a view is cheaper than a primitive with a bad API.
Do not wrap platform components that already carry the design language (
Switch
,
DateTimePicker
, stack headers,
@expo/ui
views) just to route them through the system. Native styling is the design system for those.
所有以下条件满足时,将视图升级到
src/components/
目录:
  1. 它已经(或即将)出现在两个或更多界面中。在此之前,它应存放在
    screens/<name>/
    目录下(见
    expo-project-structure
    )。
  2. 它有一个可命名的角色(如"Card"、"EmptyState"、"Badge")——而非"个人资料界面上的那个东西"。
  3. 它的API比实现更简洁。如果属性只是重新暴露所有内部样式,那它还不是可复用组件——只是界面片段。
升级路径:内联JSX →
screens/<name>/
目录下的组件 →
src/components/
目录。每次只走一步,触发条件满足时再升级——不要提前预判。错误的抽象比重复代码成本更高;视图的第二个副本比API糟糕的原语更便宜。
不要包装已经带有设计语言的平台组件(
Switch
DateTimePicker
、栈头部、
@expo/ui
视图),只是为了将它们纳入系统。原生样式本身就是这些组件的设计系统。

Where Decisions Live

决策归属

DecisionLives inExample
A visual value used anywhere twice
src/theme/
brand accent, spacing step
Structure + variants of a reused element
src/components/
Button, Card, EmptyState
One screen's private composition
screens/<name>/
profile header layout
One-off local adjustmentinline, with a commentoptical nudge on an icon
Screen titles, top-level chromenavigation stack optionsheader title, large title
决策归属位置示例
任何地方使用两次的视觉值
src/theme/
品牌强调色、间距步骤
可复用元素的结构与变体
src/components/
Button、Card、EmptyState
单个界面的私有组合
screens/<name>/
个人资料头部布局
一次性本地调整内联代码,带注释图标的视觉微调
界面标题、顶层导航栏导航栈选项头部标题、大标题

Self-Critique Pass

自我审查步骤

After building or changing a screen, screenshot it and check it against these principles (from Expo's design-principles guide). Each one maps to a system fix, not a local tweak:
  • Hierarchy / contrast - is the most important element obviously first? Fix with
    type
    ramp steps, not ad-hoc font sizes.
  • Proximity / white space - do related items sit closer than unrelated ones? Fix with
    gap
    + spacing tokens.
  • Repetition / unity - do all corners, shadows, and accents match? If not, a value escaped the theme - move it in.
  • Alignment - do edges share axes? Fix with consistent screen edge padding.
The pass is complete only when all four checks pass, or every failing value has moved into the theme or a component. If a screen fails the same check twice, the fix belongs in the theme or a component - not in the screen.
构建或修改界面后,截图并对照以下原则检查(来自Expo设计原则指南)。每个原则都对应系统层面的修复,而非本地调整:
  • 层级/对比度 - 最重要的元素是否明显突出?使用
    type
    刻度步骤修复,而非临时调整字体大小。
  • 邻近/留白 - 相关元素是否比无关元素更靠近?使用
    gap
    +间距令牌修复。
  • 重复/统一性 - 所有圆角、阴影和强调色是否一致?如果不一致,说明某个值脱离了主题——将其移入主题。
  • 对齐 - 边缘是否共享轴线?使用统一的界面边缘内边距修复。
只有当所有四项检查通过,或每个不符合的值都已移入主题或组件时,审查才算完成。如果界面两次未通过同一项检查,修复应在主题或组件中进行——而非在界面中。

Auditing an Existing App

检查现有应用

To measure drift in an app that already has screens - hardcoded hex values, arbitrary spacing, inconsistent component APIs - follow
./references/audit.md
. It contains grep-based checks, a scoring rubric, an incremental adoption order for fixing a drifted app, and templates for documenting existing components and proposing new ones.
要检测已有界面的应用是否存在偏差——硬编码十六进制值、任意间距、不一致的组件API——请遵循
./references/audit.md
。其中包含基于grep的检查、评分标准、修复偏差应用的渐进式采用顺序,以及用于文档化现有组件和提议新组件的模板。

Submitting Feedback

提交反馈

If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
bash
npx --yes submit-expo-feedback@latest --category skills --subject "expo-design-system" "<actionable feedback>"
Only submit when you have something specific and actionable to report. Include as much relevant context as possible. If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above.
如果您在使用本技能时遇到错误、误导性或过时信息,请报告给Expo以帮助改进:
bash
npx --yes submit-expo-feedback@latest --category skills --subject "expo-design-system" "<可操作的反馈>"
仅当您有具体且可操作的内容时才提交反馈。请尽可能包含相关上下文。 如果AI代理反复失败或用户不得不接管Expo任务,请加载expo-skill-feedback技能并遵循其评估流程,而非重复使用上述命令。