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 dansglobal.csscomme 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 :
- Cherchez un système déclaré. Vérifiez
package.jsonpour une bibliothèque de style - NativeWind/Tailwind (utilisezexpo-tailwind-setup), Tamagui, Restyle, Unistyles, styled-components. Puis cherchez un fichier de token :theme.ts,src/theme/,constants/theme.ts, ouconstants/Colors.ts(le défaut de create-expo-app). - 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.
- 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). - 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
spacingpour 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 (
colorsci-dessus) sont statiquement sûres : elles se résolvent sur l'appareil, donc les fichiers de token simples commetheme/typography.tspeuvent 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 objetvariants) 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 (DynamicColorIOSsur 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
gapavec les tokens d'espacement pour le rythme de mise en page (expo-native-uipréfère gap à margin). - Le padding du bord de l'écran est
spacing.mdsauf 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éfautmd. 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
styleet 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 :
- Elle apparaît (ou va apparaître) dans deux ou plus d'écrans. Jusqu'à là elle reste colocalisée dans
screens/<name>/(voirexpo-project-structure). - Elle a un rôle nommable ("Card", "EmptyState", "Badge") - pas "la chose sur l'écran de profil".
- 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.