expo-design-system

Par expo · skills

Framework (OSS). Créer et maintenir un design system au sein d'une app Expo — un thème réutilisable de design tokens (couleur, espacement, typographie, radius, ombre, motion), une structure de composants réutilisables avec des conventions de props variant/size/state, et des règles pour savoir quand extraire une vue répétée en composant partagé. À utiliser lors de la création ou de l'organisation de fichiers de thème et de design tokens (theme.ts / theme/), de l'extension d'une bibliothèque de thème ou de style existante (NativeWind, Tamagui, Restyle, Unistyles) dans son propre idiome, de la standardisation des styles pour que les écrans (y compris ceux générés par IA) paraissent cohérents et soignés, de la construction d'une bibliothèque de composants in-app, ou de l'audit d'une app pour détecter une dérive du design system (couleurs, espacements, polices codés en dur). Pour les spécificités de style par plateforme (couleurs sémantiques, règles HIG, contrôles natifs), utilisez expo-native-ui ; pour la configuration Tailwind/CSS, utilisez expo-tailwind-setup ; pour l'organisation des dossiers d'une nouvelle app, utilisez expo-project-structure.

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

Systèmes de Design Expo

Faites en sorte que tous les écrans d'une app puisent dans une seule source de vérité visuelle : un thème de tokens et un petit ensemble de composants réutilisables. Cette compétence définit où les tokens vivent, ce qu'ils couvrent, comment les composants réutilisables sont façonnés, et quand une vue répétée mérite une promotion dans le système.

Des compétences sœurs possèdent les couches autour de celle-ci :

  • expo-native-ui - règles de style de plateforme (HIG, couleurs sémantiques, contrôles, syntaxe des ombres). Suivez-la pour quelles valeurs sont natives ; suivez cette compétence pour où les valeurs vivent et comment elles sont réutilisées.
  • expo-tailwind-setup - si le projet utilise Tailwind, les tokens vivent dans global.css comme variables CSS au lieu de TypeScript. Les échelles et les noms dans cette compétence s'appliquent toujours ; seul le format de stockage change.
  • expo-project-structure - squelette de dossiers pour les nouvelles apps.

Références

Consultez ces ressources au besoin :

references/
  audit.md      Auditez une app existante pour la dérive du système de design :
                vérifications grep, rubrique de notation, plan d'adoption
                incrémentale, et modèles pour documenter ou étendre des
                composants

Adoptez Avant de Construire

Dans une app qui a déjà des écrans, la première action est la détection, pas la construction. Avant d'écrire un fichier de token :

  1. Cherchez un système déclaré. Vérifiez package.json pour une bibliothèque de style - NativeWind/Tailwind (utilisez expo-tailwind-setup), Tamagui, Restyle, Unistyles, styled-components. Puis cherchez un fichier de token : theme.ts, src/theme/, constants/theme.ts, ou constants/Colors.ts (le défaut de create-expo-app).
  2. S'il existe, c'est la source de vérité. Étendez-le dans son propre idiome - ses noms, son échelle, son format de stockage. Auditez la dérive contre ce système, pas contre les exemples ci-dessous.
  3. Si seules des valeurs de facto existent - les mêmes gris et paddings répétés sur les écrans, pas de fichier de thème - il n'y a pas encore de système. Ces valeurs sont l'entrée des échelles, pas l'autorité : dérivez les tokens à partir des plus fréquentes, alignées sur la grille 4 points (references/audit.md §5).
  4. N'introduisez jamais un second système à côté d'un existant. Un nouveau src/theme/ à côté d'une config Tamagui est une dérive du système de design, pas une adoption.

C'est seulement quand rien n'existe que les valeurs par défaut ci-dessous s'appliquent telles quelles.

Le Thème

Dans une app sans système existant, tous les design tokens vivent sous src/theme/. Dans un projet sans dossier src/ (le modèle par défaut create-expo-app a app/, components/, et constants/ à la racine), utilisez l'emplacement équivalent au niveau supérieur - généralement theme/ ou le constants/ existant - et conservez la même structure de fichier. Commencez petit et divisez par classe de token à mesure que ça grandit :

src/theme/
  colors.ts       # voir expo-native-ui "Colors" pour le motif de palette
  spacing.ts
  typography.ts
  radius.ts
  shadows.ts
  motion.ts
  index.ts        # réexporte tout : import { spacing, type } from "@/theme"

Une toute nouvelle app peut commencer avec un seul src/theme.ts contenant tous les objets ci-dessous, puis le promouvoir à la forme dossier une fois qu'une classe a besoin de son propre fichier (même règle de promotion que les composants). De toute façon, il y a exactement un point d'entrée du thème - jamais deux fichiers de token concurrents.

Règles qui rendent un thème digne d'exister :

  • Chaque valeur visuelle répétée est un token. Un littéral qui apparaît deux fois appartient au thème.
  • Les composants importent les tokens ; les écrans importent les composants. Un fichier d'écran qui importe spacing pour le padding de mise en page est correct ; un fichier d'écran redéfinissant une couleur de bouton est une dérive.
  • Ne codez jamais en dur les couleurs hex, les tailles de police ou les multiples d'espacement en dehors de src/theme/. Les valeurs ponctuelles qui sont réellement locales (un ajustement optique 17px d'une icône) peuvent rester en ligne - avec un commentaire expliquant pourquoi.

Couleurs

Construisez la palette à partir des couleurs sémantiques de plateforme : Color de expo-router enveloppé dans Platform.select, centralisé dans theme/colors.ts. Les couleurs sémantiques se résolvent sur l'appareil et s'adaptent automatiquement au clair/sombre - préférez-les pour les arrière-plans, les étiquettes et les séparateurs. (expo-native-ui "Colors" couvre la palette complète et la justification ; la version minimale est :)

// 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",
  })!,
  // Délibérément fixé : le texte sur une surface tintée (accent) reste blanc dans les deux modes.
  onTint: "#ffffff",
};

Ajoutez les couleurs de marque comme des paires clair/sombre explicites seulement quand la marque exige des valeurs que la plateforme ne fournit pas :

// theme/colors.ts (ajouts de marque)
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"];
}

Gardez l'ensemble de marque minuscule (accent, accentContrast, peut-être une teinte par fonctionnalité). Tout le reste reste sémantique.

Statiquement sûr vs hook uniquement. Les deux modèles ci-dessus ont une portée différente - gardez la limite explicite :

  • Les couleurs sémantiques/de plateforme (colors ci-dessus) sont statiquement sûres : elles se résolvent sur l'appareil, donc les fichiers de token simples comme theme/typography.ts peuvent les importer à la portée du module.
  • Les paires claires/sombres de marque sont hook uniquement : useBrandColors() lit le schéma de couleur au moment du rendu, donc les couleurs de marque ne peuvent être appliquées que à l'intérieur des composants. Un fichier de token statique ne peut pas appeler le hook.
  • Ne mélangez jamais les deux dans un fichier. Si un style statique (une étape de rampe type, un objet variants) a besoin de l'accent de marque, soit appliquez la couleur de marque dans le composant au moment du rendu, soit enveloppez la paire dans une couleur dynamique statiquement sûre (DynamicColorIOS sur iOS) pour la rendre statiquement sûre.

Espacement

Une échelle, basée sur une grille 4 points. Nommez les étapes par taille, pas par usage :

// theme/spacing.ts
export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  xxl: 48,
} as const;
  • Utilisez gap avec les tokens d'espacement pour le rythme de mise en page (expo-native-ui préfère gap à margin).
  • Le padding du bord de l'écran est spacing.md sauf si la conception dit autrement - choisissez-en un et conservez-le.
  • Si une mise en page a besoin d'une valeur entre les étapes, utilisez l'étape la plus proche. La grille est le point.
  • Si le même multiple intermédiaire de 4 se répète continuellement (12 et 20 sont courants), ajoutez-le à l'échelle comme étape nommée au lieu de disperser des littéraux. La liste blanche d'audit doit alors aussi l'inclure.

Typographie

Définissez des styles de texte nommés, pas des tailles de police brutes. Reflet la rampe de plateforme (Apple text styles) pour que les tailles semblent natives :

// 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>;

Si le projet contient des fichiers de police statiques (un fichier par poids, chargés avec expo-font ou le plugin de config), définissez le poids via les noms fontFamily à la place et omettez fontWeight - autrement iOS synthétise le poids ou revient à la police système :

headline: { fontSize: 17, fontFamily: "SFProRounded-Semibold", color: colors.label },

Exposez-les via un composant pour que les écrans ne touchent jamais à 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} />;
}

Les titres d'écran proviennent toujours des options d'en-tête de la pile de navigation (expo-native-ui règle), donc largeTitle est surtout pour les contextes non-pile.

Radius

// theme/radius.ts
export const radius = {
  sm: 8,
  md: 12,
  lg: 16,
  full: 9999, // capsules
} as const;

Appairez chaque radius non-capsule avec borderCurve: "continuous" (par expo-native-ui).

Ombres

Les ombres sont des chaînes boxShadow (jamais les props shadow/elevation héritées - voir expo-native-ui). Deux ou trois niveaux d'élévation suffisent :

// 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;

Mouvement

Durées et configurations partagées spring/easing, pour que les animations sur l'app semblent liées :

// theme/motion.ts
export const motion = {
  fast: 150, // retour d'état : appui, basculement
  base: 250, // transitions d'élément : entrée/sortie
  slow: 400, // grandes surfaces : feuilles, écrans
} as const;

Caveat Reanimated : ne passez pas les valeurs de token Color/PlatformColor dans les styles Reanimated - utilisez des couleurs statiques là-bas (voir expo-native-ui).

Composants Réutilisables

Le thème contrôle les valeurs ; les composants contrôlent la structure. Les primitives partagées vivent dans src/components/ (voir expo-project-structure).

Le contrat du composant

Chaque primitive de système de design définit, explicitement :

  • Variants - intention visuelle : primary, secondary, ghost, destructive. Ajoutez une variant seulement quand un vrai écran en a besoin.
  • Tailles - sm, md, lg. Par défaut md. Les tailles mappent aux tokens d'espacement/typographie, jamais à de nouveaux nombres.
  • États - défaut, appuyé (pas hover - c'est du toucher), désactivé, chargement. Gérez appuyé avec une fonction de style Pressable ; ne laissez jamais un élément tapotable sans retour d'appui.
  • Substitution de style - acceptez une prop style et fusionnez-la en dernier, pour que les appelants puissent ajuster la mise en page (marges, flex) sans fork du composant. Les appelants peuvent substituer la mise en page, pas l'identité - un appelant changeant les couleurs d'un bouton est un signal que l'ensemble de variants manque quelque chose.
// 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, // les substitutions de l'appelant fusionnent en dernier
      ]}
    >
      {loading ? (
        <ActivityIndicator color={variants[variant].color as string} />
      ) : (
        <ThemedText variant="headline" style={{ color: variants[variant].color }}>
          {title}
        </ThemedText>
      )}
    </Pressable>
  );
}

Composition plutôt que configuration

Quand les props d'un composant commencent à décrire du contenu (leftIcon, subtitle, footerText, badgeCount), arrêtez d'ajouter des props et acceptez children à la place. Un Card qui rend children avec un padding de token survit à n'importe quel Card avec douze props de contenu. Réservez les props au contrat ci-dessus : variant, size, state, style.

Quand extraire - et quand ne pas

Promouvez une vue dans src/components/ quand tous ces critères tiennent :

  1. Elle apparaît (ou va apparaître) dans deux ou plus d'écrans. Jusqu'à là elle reste colocalisée dans screens/<name>/ (voir expo-project-structure).
  2. Elle a un rôle nommable ("Card", "EmptyState", "Badge") - pas "la chose sur l'écran de profil".
  3. Son API est plus petite que son implémentation. Si les props exposeraient juste chaque style interne, ce n'est pas encore un composant réutilisable - c'est un fragment d'écran.

Chemin de promotion : JSX en ligne → composant dans screens/<name>/src/components/. Bougez une étape à la fois, quand le déclencheur se déclenche - jamais spéculativement. Les mauvaises abstractions coûtent plus que la duplication ; une deuxième copie d'une vue est moins chère qu'une primitive avec une mauvaise API.

Faites ne pas envelopper les composants de plateforme qui portent déjà le langage de design (Switch, DateTimePicker, en-têtes de pile, les vues @expo/ui) juste pour les router via le système. Le style natif est le système de design pour ceux-là.

Où Vivent les Décisions

Décision Vit dans Exemple
Une valeur visuelle utilisée partout deux fois src/theme/ accent de marque, étape d'espacement
Structure + variants d'un élément réutilisé src/components/ Button, Card, EmptyState
Composition privée d'un écran screens/<name>/ layout d'en-tête de profil
Ajustement local ponctuel en ligne, avec un commentaire ajustement optique sur une icône
Titres d'écran, chrome de haut niveau options de pile de navigation titre d'en-tête, grand titre

Passage d'Auto-Critique

Après avoir construit ou changé un écran, prenez une capture d'écran et vérifiez-la contre ces principes (du guide des principes de design Expo). Chacun mappe à une correction système, pas un ajustement local :

  • Hiérarchie / contraste - l'élément le plus important est-il clairement en premier ? Corrigez avec les étapes de rampe type, pas des tailles de police ad hoc.
  • Proximité / espace blanc - les articles liés sont-ils plus proches que les non-liés ? Corrigez avec gap + tokens d'espacement.
  • Répétition / unité - tous les coins, ombres et accents correspondent-ils ? Si non, une valeur a échappé au thème - insérez-la.
  • Alignement - les bords partagent-ils des axes ? Corrigez avec un padding cohérent du bord d'écran.

Le passage est complet seulement quand tous les quatre contrôles passent, ou chaque valeur défaillante a bougé dans le thème ou un composant. Si un écran échoue le même contrôle deux fois, la correction appartient au thème ou à un composant - pas à l'écran.

Auditer une App Existante

Pour mesurer la dérive dans une app qui a déjà des écrans - valeurs hex codées en dur, espacement arbitraire, APIs de composants incohérentes - suivez ./references/audit.md. Il contient des vérifications basées sur grep, une rubrique de notation, un ordre d'adoption incrémentale pour réparer une app à la dérive, et des modèles pour documenter les composants existants et proposer de nouveaux.

Soumettre des Commentaires

Si vous rencontrez des erreurs, des informations trompeuses ou obsolètes dans cette compétence, signalez-les pour qu'Expo améliore :

npx --yes submit-expo-feedback@latest --category skills --subject "expo-design-system" "<commentaires actionnables>"

Soumettez seulement quand vous avez quelque chose de spécifique et actif à signaler. Incluez autant de contexte pertinent que possible.

Si un agent IA a échoué à plusieurs reprises ou l'utilisateur a dû reprendre une tâche Expo, chargez la compétence expo-skill-feedback et suivez son flux d'évaluation des candidats au lieu de réutiliser la commande ci-dessus.

Skills similaires