Skip to Content
React Native4. Styling & layout4.5 Design system de Drill

Chapitre 4.5 — Le design system de Drill

Où on en est : NativeWind installé, tokens en place. Dernier chapitre de style : on fige le kit UI de Drill — les composants qu’on réutilisera dans TOUTES les parties (et toutes vos apps perso).

⏱️ TL;DR — Six composants suffisent à Drill : AppText (typo thémée), Screen (page + safe areas), Card, Button (3 variantes), Badge (difficulté/streak), ProgressBar. Chacun : petite API, className extensible, accessibilité incluse. C’est votre premier design system RN — le squelette de chaque app que vous ferez ensuite.

🎯 Objectifs

  • Construire les 6 composants de base avec une API cohérente.
  • Systématiser le pattern « variantes par mapping de classes ».
  • Rendre chaque composant extensible (className) sans le rendre flou.
  • Constituer un kit copiable pour vos futures apps (P19 s’en servira).

Les règles du kit

  1. Petites APIs : 2-4 props par composant, des variantes nommées plutôt que des booléens accumulés.
  2. Classes littérales en mappings (contrainte NativeWind — chapitre 4.4).
  3. className pass-through en dernier : l’appelant peut ajuster les marges sans forker.
  4. Accessibilité par défaut : roles et labels inclus DANS le kit, pas laissés aux écrans.

AppText — la typo une fois pour toutes

Pas d’héritage de police en RN (chapitre 3.1) → un composant texte à soi est la parade standard :

// components/ui/AppText.tsx import { Text, TextProps } from 'react-native' import { cn } from '../../lib/cn' type Variant = 'title' | 'heading' | 'body' | 'caption' | 'mono' const variants: Record<Variant, string> = { title: 'text-3xl font-extrabold text-ink-100', heading: 'text-xl font-bold text-ink-100', body: 'text-base text-ink-100', caption: 'text-[13px] text-ink-400', mono: 'font-mono text-base text-brand-light', // extraits de code dans les questions } type Props = TextProps & { variant?: Variant; className?: string } export function AppText({ variant = 'body', className, ...rest }: Props) { return <Text className={cn(variants[variant], className)} {...rest} /> }
// lib/cn.ts — le mini-helper (logique pure → lib/) export function cn(...parts: Array<string | false | null | undefined>) { return parts.filter(Boolean).join(' ') }

Désormais, la règle du cours : plus jamais <Text> nu dans un écran — toujours AppText. Un changement de police (Partie 12, quand on soignera l’identité) = une ligne ici.

Screen — la page standard

// components/ui/Screen.tsx import { ScrollView, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { cn } from '../../lib/cn' type Props = { children: React.ReactNode scroll?: boolean // contenu borné → true ; l'écran gère un FlatList → false className?: string } export function Screen({ children, scroll = false, className }: Props) { const insets = useSafeAreaInsets() const Wrapper = scroll ? ScrollView : View return ( <View className="flex-1 bg-ink-950"> <Wrapper className={cn('flex-1', className)} style={{ paddingTop: insets.top }} {...(scroll ? { contentContainerStyle: { padding: 16, paddingBottom: insets.bottom + 16 } } : {})} > {children} </Wrapper> </View> ) }

Tout le savoir des chapitres 3.6 et 4.2 encapsulé : fond qui déborde sous les barres, contenu protégé, et le choix ScrollView/View explicite. Les écrans deviennent : <Screen>…</Screen>.

Button — les 3 variantes

// components/ui/Button.tsx import { Pressable, PressableProps } from 'react-native' import { AppText } from './AppText' import { cn } from '../../lib/cn' type Variant = 'primary' | 'secondary' | 'ghost' const base = 'min-h-[52px] flex-row items-center justify-center gap-2 rounded-xl px-5' const variants: Record<Variant, { box: string; label: string }> = { primary: { box: 'bg-brand active:bg-brand/85', label: 'text-white font-bold text-base' }, secondary: { box: 'border border-ink-700 bg-ink-900 active:opacity-85', label: 'text-ink-100 font-semibold text-base' }, ghost: { box: 'active:opacity-70', label: 'text-brand-light font-semibold text-base' }, } type Props = PressableProps & { label: string variant?: Variant className?: string } export function Button({ label, variant = 'primary', className, disabled, ...rest }: Props) { const v = variants[variant] return ( <Pressable accessibilityRole="button" accessibilityState={{ disabled: !!disabled }} disabled={disabled} className={cn(base, v.box, disabled && 'opacity-40', className)} {...rest} > <AppText className={v.label}>{label}</AppText> </Pressable> ) }

Usage : <Button label="Lancer le quiz" onPress={start} />, <Button label="Annuler" variant="ghost" />. L’AnswerButton du chapitre 3.3 reste à part — c’est un composant métier (états correct/incorrect), pas un bouton générique. La frontière kit générique / composants métier est une vraie décision d’architecture : le kit ne connaît pas le quiz.

Card, Badge, ProgressBar

// components/ui/Card.tsx export function Card({ className, ...rest }: ViewProps & { className?: string }) { return <View className={cn('rounded-2xl border border-ink-700 bg-ink-900 p-4', className)} {...rest} /> }
// components/ui/Badge.tsx — difficulté des questions, streak… type Tone = 'neutral' | 'success' | 'danger' | 'streak' | 'brand' const tones: Record<Tone, string> = { neutral: 'bg-ink-700/50 text-ink-100', success: 'bg-green-500/15 text-green-500', danger: 'bg-red-500/15 text-red-500', streak: 'bg-streak/15 text-streak', brand: 'bg-brand/15 text-brand-light', } export function Badge({ label, tone = 'neutral' }: { label: string; tone?: Tone }) { return ( <View className={cn('self-start rounded-full px-2.5 py-1', tones[tone].split(' ')[0])}> <AppText className={cn('text-xs font-bold', tones[tone].split(' ')[1])}>{label}</AppText> </View> ) }
// components/ui/ProgressBar.tsx — progression dans la session (question 3/10) export function ProgressBar({ ratio }: { ratio: number }) { return ( <View accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: Math.round(ratio * 100) }} className="h-2 overflow-hidden rounded-full bg-ink-700/60" > <View className="h-full rounded-full bg-brand" style={{ width: `${ratio * 100}%` }} /> </View> ) }

ProgressBar illustre la cohabitation className/style : structure en classes, largeur calculée en style (elle sera animée en Partie 10 sans changer l’API).

📱 Essaie sur ton téléphone — Montez un écran vitrine app/kitchen-sink.tsx : chaque composant du kit dans tous ses états. Les design systems du monde entier ont cet écran — c’est votre page de test visuel permanente (et votre première vraie satisfaction esthétique du cours).

💡 Pour un dev Next.js — Ce kit, c’est votre components/ui/ de shadcn/ui, en RN. Il n’existe pas (encore) de shadcn RN dominant — les apps sérieuses écrivent ces 200 lignes. Bonne nouvelle : vous venez de le faire une fois pour toutes ; les projets guidés (P19) le copieront tel quel.

✏️ Exercices

1. Ajoutez au kit un composant Divider (filet horizontal thémé) et un Row (flex-row items-center gap paramétrable) — les deux plus petits composants les plus rentables.

✅ Solution

export function Divider({ className }: { className?: string }) { return <View className={cn('h-px bg-ink-700/60', className)} /> } export function Row({ gap = 3, className, ...rest }: ViewProps & { gap?: 2 | 3 | 4; className?: string }) { const gaps = { 2: 'gap-2', 3: 'gap-3', 4: 'gap-4' } as const return <View className={cn('flex-row items-center', gaps[gap], className)} {...rest} /> }

Noter le gap en union bornée mappée sur des classes littérales — la contrainte NativeWind transformée en API propre.

2. Button doit accepter un état loading : spinner à la place du label, interactions coupées, accessibilité correcte.

✅ Solution

import { ActivityIndicator } from 'react-native' type Props = PressableProps & { label: string; variant?: Variant; loading?: boolean; className?: string } export function Button({ loading, disabled, label, … }: Props) { const off = disabled || loading return ( <Pressable disabled={off} accessibilityState={{ disabled: !!off, busy: !!loading }} className={cn(base, v.box, off && 'opacity-40', className)} {...rest} > {loading ? <ActivityIndicator color="#fff" /> : <AppText className={v.label}>{label}</AppText>} </Pressable> ) }

ActivityIndicator = le spinner natif. accessibilityState.busy annonce le chargement à TalkBack.

3. Un écran a besoin d’une Card cliquable. Deux options : ajouter onPress à Card, ou composer Pressable + Card. Laquelle choisit un design system sain, et pourquoi ?

✅ Solution

Composer : <Pressable className="active:opacity-85"><Card>…</Card></Pressable> (ou un PressableCard métier local). Ajouter onPress à Card la transformerait en « peut-être bouton » : double responsabilité, accessibilité ambiguë (role ?), et l’API enfle pour tous les usages non cliquables. Un composant = un rôle ; la composition fait le reste.

🧠 Quiz

1. Pourquoi un composant AppText est-il quasi obligatoire en RN ?

Réponse

Aucun héritage de police/couleur entre composants : sans point central, chaque Text redéfinit tout (et diverge). AppText porte les variantes typographiques et le thème — un changement de police = une ligne.

2. Que encapsule le composant Screen ?

Réponse

Le fond plein écran sous les barres système, les safe areas (insets) sur le contenu, et le choix ScrollView (contenu borné) vs View (l’écran contient une FlatList) — les acquis des chapitres 3.6/4.2.

3. Pourquoi les variantes sont-elles des mappings d’objets et pas des template strings ?

Réponse

Contrainte NativeWind : les classes doivent être littérales dans le source pour être compilées. Record<Variant, string> garde chaque classe écrite en toutes lettres, avec le bonus du typage exhaustif.

4. Où passe la frontière entre le kit ui/ et les composants métier ?

Réponse

Le kit (Button, Card, Badge…) ne connaît rien au domaine quiz ; les composants métier (AnswerButton, ThemeCard, StreakLabel) encodent les règles du jeu et composent le kit. On peut copier ui/ dans une autre app sans emporter Drill.

5. Comment ProgressBar concilie-t-elle NativeWind et valeur dynamique ?

Réponse

Structure en className (piste, radius, couleurs) ; la largeur calculée (width: ratio%) en prop style — le partage canonique classes déclaratives / style calculé.


👉 Partie suivante : Partie 5 — Navigation avec Expo Router — plusieurs écrans, des tabs, des paramètres : Drill devient une vraie app.