Chapitre 4.3 — Thème & dark mode
Où on en est : vous savez styler et disposer. Mais nos couleurs sont éparpillées en dur (
#58c4dccopié 12 fois). On centralise : palette, tokens, et dark mode propre.
⏱️ TL;DR — On définit la palette de Drill dans
constants/theme.ts(tokens sémantiques :background,surface,text,accent…), on lit le mode système avecuseColorScheme, et on distribue le thème par un hook maisonuseTheme. Drill assume un parti pris : sombre par défaut, clair disponible. Ce système sera remplacé au chapitre suivant par les variantesdark:de NativeWind — mais les tokens, eux, survivront.
🎯 Objectifs
- Extraire une palette en tokens sémantiques (pas « bleu », mais « accent »).
- Lire et suivre le thème système avec useColorScheme.
- Construire un
useThemepropre et l’utiliser dans les composants. - Comprendre pourquoi les tokens précèdent l’outil (StyleSheet aujourd’hui, NativeWind demain).
La palette de Drill
Un quiz se joue souvent le soir, dans le métro, au lit : sombre par défaut. La palette, inspirée du cyan React :
// constants/theme.ts
export const palette = {
// neutres
ink950: '#0b1216', // fond app (sombre)
ink900: '#101b21', // surfaces (cartes)
ink700: '#1e3a45', // bordures
ink400: '#7a8b94', // texte secondaire
ink100: '#e2e8f0', // texte principal (sur sombre)
white: '#ffffff',
// marque
cyan500: '#087ea4',
cyan300: '#58c4dc',
// sémantiques de jeu
green500: '#22c55e', // bonne réponse
red500: '#ef4444', // mauvaise réponse
amber400: '#fbbf24', // streak / records
} as const
export type ThemeColors = {
background: string
surface: string
border: string
text: string
textMuted: string
accent: string
success: string
danger: string
highlight: string
}
export const darkTheme: ThemeColors = {
background: palette.ink950,
surface: palette.ink900,
border: palette.ink700,
text: palette.ink100,
textMuted: palette.ink400,
accent: palette.cyan300,
success: palette.green500,
danger: palette.red500,
highlight: palette.amber400,
}
export const lightTheme: ThemeColors = {
background: '#f6fafc',
surface: palette.white,
border: '#d8e3e9',
text: '#0f2530',
textMuted: '#5b707b',
accent: palette.cyan500,
success: '#15803d',
danger: '#b91c1c',
highlight: '#b45309',
}La distinction clé : la palette nomme des couleurs (cyan300), le thème nomme des
rôles (accent, surface). Les composants ne parlent QUE les rôles — c’est ce qui rend le
dark/light trivial : même rôle, valeur différente.
💡 Pour un dev Next.js — C’est exactement vos variables CSS
--background/--foreground
prefers-color-scheme, en objets TS. Même philosophie de design tokens ; seule la mécanique de distribution change (pas de CSS variables → un hook).
Lire le système : useColorScheme
import { useColorScheme } from 'react-native'
const scheme = useColorScheme() // 'dark' | 'light' | nullLe hook est réactif : l’utilisateur bascule son téléphone en mode sombre → re-render.
Condition : "userInterfaceStyle": "automatic" dans app.json (posé au chapitre 2.5 — sans ça,
Android force une valeur fixe).
Le hook useTheme
// hooks/useTheme.ts
import { useColorScheme } from 'react-native'
import { darkTheme, lightTheme, ThemeColors } from '../constants/theme'
export function useTheme(): ThemeColors {
const scheme = useColorScheme()
return scheme === 'light' ? lightTheme : darkTheme // sombre par défaut (null → dark)
}Usage dans un composant — les styles dépendant du thème deviennent dynamiques :
// components/ThemeCard.tsx — version thémée
export function ThemeCard({ title, questionCount, emoji }: Props) {
const t = useTheme()
return (
<View style={[styles.card, { backgroundColor: t.surface, borderColor: t.border }]}>
<Text style={styles.emoji}>{emoji}</Text>
<View style={styles.body}>
<Text style={[styles.title, { color: t.text }]} numberOfLines={1}>{title}</Text>
<Text style={[styles.meta, { color: t.textMuted }]}>{questionCount} questions</Text>
</View>
</View>
)
}
const styles = StyleSheet.create({
// ce qui ne dépend PAS du thème reste statique
card: { flexDirection: 'row', alignItems: 'center', gap: 12, borderRadius: 14, borderWidth: 1, padding: 16 },
emoji: { fontSize: 32 },
body: { flex: 1 },
title: { fontSize: 18, fontWeight: '700' },
meta: { fontSize: 13 },
})Le pattern : structure statique dans StyleSheet, couleurs du thème en tableau. Verbeux ?
Oui — c’est précisément ce que NativeWind va compresser au chapitre suivant (bg-surface dark:bg-… → une classe). Mais les tokens définis ici sont EXACTEMENT ceux qu’on branchera
dans la config Tailwind : ce chapitre n’est pas jetable, c’est la fondation.
Et le choix manuel de l’utilisateur ?
Un vrai réglage de thème offre trois options : système / sombre / clair. Il faut donc UNE
information persistée (la préférence) combinée au système. On l’implémentera proprement en
Partie 6 (AsyncStorage + Zustand) — le useTheme ci-dessus prendra alors la préférence en
compte sans que aucun composant ne change : ils parlent aux rôles, pas à la mécanique.
⚠️ Piège — Ne testez pas votre dark mode uniquement en dev : certaines couleurs « en dur » oubliées (un
#fffde fond, lecolord’un TextInput — chapitre 3.5 !) n’explosent que dans l’autre mode. Réflexe : basculer le téléphone light/dark fait partie de la checklist de CHAQUE écran. Et bannissez les hex littéraux dans les composants — si ce n’est pas un token, c’est un bug futur.
✏️ Exercices
1. Migrez AnswerButton (chapitre 3.3) vers les tokens : plus aucun hex littéral dedans.
✅ Solution
const t = useTheme()
style={({ pressed }) => [
styles.base,
{ backgroundColor: t.surface, borderColor: t.border },
state === 'correct' && { borderColor: t.success, backgroundColor: t.success + '1f' },
state === 'incorrect' && { borderColor: t.danger, backgroundColor: t.danger + '1f' },
state === 'disabled' && styles.dimmed,
pressed && interactive && styles.pressed,
]}(+ '1f' ajoute ~12 % d’alpha à un hex 6 digits — l’astuce des fonds teintés. Le label passe
en color: t.text.)
2. Ajoutez un rôle timerWarning (le timer sous 5 s) aux deux thèmes, sans casser le typage.
✅ Solution
Ajouter timerWarning: string au type ThemeColors, puis TS force à le définir dans
darkTheme (palette.amber400) ET lightTheme ('#b45309'). C’est l’intérêt du type
explicite : impossible d’oublier un mode. Usage : color: t.timerWarning quand
secondsLeft <= 5.
3. Pourquoi préférer surface/accent à gray900/cyan300 dans les composants ? Donnez
deux scénarios concrets où ça paie.
✅ Solution
(1) Dark/light : surface change de valeur selon le mode ; un composant écrit en gray900
serait faux en clair. (2) Rebranding/thèmes multiples : passer Drill en thème « ambre » =
changer les thèmes, zéro composant touché. Les rôles découplent l’intention (« c’est une
surface ») de la réalisation (« c’est #101b21 aujourd’hui »).
🧠 Quiz
1. Différence entre palette et thème ?
Réponse
La palette nomme des couleurs (cyan300) ; le thème mappe des rôles sémantiques (accent, surface, text) vers ces couleurs — un mapping par mode (dark/light). Les composants ne consomment que les rôles.
2. Que retourne useColorScheme et à quelle condition suit-il le système ?
Réponse
'dark' | 'light' | null, réactif aux changements système — à condition que
userInterfaceStyle: "automatic" soit dans app.json.
3. Quelle partie d’un style reste dans StyleSheet.create avec ce système ?
Réponse
La structure (layout, tailles, radius, typo) — tout ce qui ne dépend pas du mode. Les
couleurs du thème s’ajoutent en tableau : [styles.card, { backgroundColor: t.surface }].
4. Pourquoi Drill est-il sombre par défaut (null → dark) ?
Réponse
Parti pris produit : usage du soir/mobilité, confort visuel d’un quiz, identité visuelle. Le
null (système indéterminé) retombe sur le mode principal du design.
5. Où sera branché le choix manuel système/sombre/clair, et pourquoi pas maintenant ?
Réponse
En Partie 6 : il faut persister la préférence (AsyncStorage) et la distribuer (Zustand). Grâce aux rôles, ce branchement ne modifiera aucun composant — seulement le hook useTheme.
👉 Chapitre suivant : 4.4 — NativeWind — le retour de Tailwind. Vos classes, vos réflexes, dans React Native.