Composants & style : NativeWind + CVA
Comment ton app fabrique des briques visuelles réutilisables et cohérentes sans jamais répéter le
même style à dix endroits. On va prendre tout son temps : d'abord la philosophie (qu'est-ce qu'un
bon composant, pourquoi écrire le style avec des classes utilitaires), puis l'outillage
(cn(), CVA), puis on décortique ligne par ligne le vrai Button de
Halterofit — qui réunit absolument tout, y compris le contexte React vu ailleurs dans le guide.
- NativeWind : on écrit le style avec des classes utilitaires dans la
prop
className, exactement comme Tailwind sur le web.className="bg-primary px-4 rounded-md"veut dire : fond de la couleur primaire, marge intérieure horizontale, coins arrondis. Pas de fichier CSS séparé, pas de feuille de style à maintenir à côté. - CVA (class-variance-authority) : une petite bibliothèque pour décrire les variantes d'un composant — un bouton « primaire », « danger », « contour », en taille petite/normale/grande — et obtenir automatiquement les bonnes classes en donnant juste un nom. On écrit le style de chaque variante une seule fois.
Le fil rouge du module, c'est : le style vit à un seul endroit, et on le réutilise partout. Tout ce qui suit sert cette idée.
La composition de composants : petites briques plutôt que gros monolithe
Avant même de parler de couleurs et de classes, il faut comprendre comment on découpe une
interface. C'est la question la plus importante de l'architecture front, et c'est aussi la plus
facile à rater. L'instinct du débutant, c'est de fabriquer un seul gros composant
SuperBouton qui accepte trente props : estPrimaire, estDanger,
aUneIcône, texteEnGras, arrondi, pleineLargeur…
On appelle ça un composant monolithique configurable. Au début c'est pratique. Très
vite, c'est un cauchemar : chaque nouveau besoin ajoute une prop, le corps du composant se remplit de
if imbriqués, et personne n'ose plus y toucher de peur de casser un cas oublié.
L'approche inverse, celle que privilégie React et que suit Halterofit, c'est la
composition : on assemble une interface comme on assemble des briques Lego. On
écrit beaucoup de petits composants qui font une seule chose chacun, et on les combine. Un
écran n'est pas un bloc géant : c'est un <Screen> qui contient une
<Card> qui contient un <Button> et du <Text>.
Chaque brique reste petite, lisible, testable, et réutilisable ailleurs.
L'outil clé de la composition, c'est la prop spéciale children (« les enfants »). Quand
tu écris <Card>du contenu</Card>, tout ce qui se trouve entre la balise
ouvrante et la balise fermante arrive dans le composant Card sous le nom
children. La carte n'a pas besoin de savoir ce qu'elle contient : elle dessine
son cadre, son ombre, ses marges, et place children au milieu. C'est ce qui rend un
composant vraiment générique : il fournit une forme, et tu y glisses n'importe quel
contenu.
// Un composant « contenant » : il ne connaît pas son contenu, il l'emballe.
function Card({ children }) {
return (
// La carte impose son cadre ; children est ce qu'on a mis entre les balises.
<View className="rounded-xl border border-border bg-card p-4">
{children}
</View>
);
}
// À l'usage : on COMPOSE. La Card emballe un titre et un bouton, sans le savoir d'avance.
<Card>
<Text>Séance du jour</Text>
<Button variant="default">Commencer</Button>
</Card>
La carte fournit le cadre, le bouton fournit l'action, le texte fournit le contenu. Personne ne fait tout. C'est la composition : on emboîte des petites briques au lieu d'écrire un monstre paramétrable.
On distingue souvent deux familles de composants. Les composants de présentation
(presentational) ne s'occupent que de l'apparence : un bouton, une carte, une étiquette. Ils
reçoivent des props, ils affichent, point. Ils ne savent rien de la base de données ni de la logique
métier. Les composants conteneurs (container), à l'inverse, orchestrent : ils
vont chercher des données, gèrent l'état, décident quoi afficher, et délèguent le
comment aux composants de présentation. Cette séparation rend l'app prévisible : tu sais où
regarder. Un bug d'apparence ? C'est dans un composant de présentation. Un bug de données ? C'est dans
un conteneur. Le Button qu'on va étudier est un pur composant de présentation : il ne
connaît rien de tes séances, il sait juste être un beau bouton.
Quand on rassemble toutes ces petites briques de présentation cohérentes dans un même dossier, on
obtient un design system : une bibliothèque maison de composants qui partagent la
même langue visuelle. Dans Halterofit, ce dossier s'appelle components/ui/ — tu y trouves
button.tsx, text.tsx, et leurs cousins. C'est le vocabulaire visuel de
l'app. Quand tout l'app pioche dans le même jeu de briques, l'app a l'air « d'une seule main »,
même écrite à plusieurs.
La règle pratique : quand un composant devient difficile à lire, ce n'est presque jamais qu'il manque une prop — c'est qu'il fait trop de choses. Coupe-le en deux. « Préférer la composition à la configuration » est l'un des proverbes les plus utiles du développement React.
La philosophie utility-first : du style sans fichier CSS
Passons au style. Pendant vingt ans, la pratique standard du web a été d'écrire le style dans des
fichiers CSS séparés. On inventait un nom de classe sémantique — .card,
.card__titre, .bouton-principal — puis on allait dans un autre fichier
écrire ce que ce nom voulait dire. C'est élégant sur le papier. En pratique, ça pose trois problèmes
tenaces. D'abord, nommer est difficile : tu passes un temps fou à inventer
.card-header-wrapper-inner et à te demander si ça existe déjà. Ensuite, le CSS est
global : une classe écrite ici peut affecter quelque chose là-bas, et ces collisions
sont la hantise des grosses bases de code. Enfin, le CSS ne meurt jamais : tu
supprimes un composant, mais qui ose supprimer ses classes CSS ? « Au cas où ». La feuille de style
grossit éternellement.
L'approche utility-first (« d'abord les utilitaires »), popularisée par Tailwind et
portée dans le monde React Native par NativeWind, retourne le problème. Au lieu d'inventer des noms,
tu composes ton style directement à partir d'un dictionnaire de petites classes utilitaires,
chacune faisant une seule chose : px-4 (marge intérieure horizontale),
rounded-md (coins moyennement arrondis), bg-primary (fond couleur primaire),
flex-row (disposition en ligne). Tu assembles ces briques dans className, sur
place, sans jamais quitter le composant.
// Approche classique « CSS séparé » : on invente un nom ici…
<View className="carte-seance" />
// …et on va dans un AUTRE fichier dire ce que ce nom veut dire. Deux endroits à tenir à jour.
// Approche utility-first : le style EST là, lisible, local. Aucun nom à inventer.
<View className="rounded-xl border border-border bg-card p-4" />
Dans la version utility-first, tu lis le composant et tu sais immédiatement à quoi il ressemble. Pas d'aller-retour entre deux fichiers, pas de nom mystérieux à décoder.
Les avantages sont concrets. La cohérence : les classes proviennent d'une
échelle fixe (les marges vont par crans p-1, p-2, p-4… pas
n'importe quelle valeur en pixels), donc tout l'app respire au même rythme. La vitesse : tu
stylises en tapant, sans changer de fichier ni inventer de nom. La tranquillité d'esprit :
supprimer un composant supprime son style avec lui, puisque le style vivait dedans — pas de CSS mort
qui s'accumule. Et comme tu réutilises toujours le même petit vocabulaire, l'app reste homogène
presque sans effort.
La critique la plus courante, il faut la connaître pour ne pas être surpris : c'est
la verbosité. Un composant un peu riche peut avoir une longue ligne de classes
(className="flex-row items-center justify-center gap-2 rounded-md px-4 py-2 bg-primary…"),
ce qui pique les yeux au premier abord et fait dire « mais c'est illisible ! ». La réponse de la
communauté tient en deux points. Un : on lit ces classes très vite une fois habitué, car chacune dit
exactement une chose. Deux — et c'est tout l'objet de ce module — dès qu'une combinaison se répète, on
ne la recopie pas : on l'extrait dans un composant réutilisable (le Button)
ou dans une variante CVA. La verbosité reste alors confinée dans le design system, et
le reste de l'app écrit simplement <Button variant="default">.
Souviens-toi : en React Native, il n'y a pas de CSS du tout. Le style « natif » se fait avec des
objets JavaScript (StyleSheet.create({...})) — verbeux et dispersé. NativeWind ajoute par
dessus la possibilité d'écrire ce même style avec des classes Tailwind dans className,
puis les traduit en style natif sous le capot. C'est donc le pont parfait pour quelqu'un qui connaît
(ou apprend) Tailwind : tu écris le style « comme sur le web », et React Native l'applique « comme du
natif ». On a posé ce décor dans le module sur React Native (le style sans CSS) — ce module-ci en est
la suite directe.
L'outil de base : la fonction cn()
Dès qu'on veut composer des classes de façon un peu intelligente — en ajouter selon une condition, ou
laisser l'extérieur en surcharger certaines — la simple concaténation de chaînes ne suffit plus. C'est
le rôle d'une petite fonction utilitaire que tu verras dans presque chaque composant de l'app :
cn(). Elle est minuscule mais elle fait deux choses précieuses.
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
// cn = « class names ». Elle combine des classes ET résout les conflits Tailwind.
export function cn(...inputs: ClassValue[]) {
// 1. clsx aplatit et filtre : il jette les valeurs « fausses » (false, null, undefined).
// 2. twMerge passe derrière et, en cas de conflit Tailwind, garde la DERNIÈRE classe.
return twMerge(clsx(inputs));
}
// Exemples de comportement :
cn('px-4 py-2', 'bg-primary') // → 'px-4 py-2 bg-primary' (simple assemblage)
cn('px-4', isActive && 'bg-primary') // → ajoute 'bg-primary' SEULEMENT si isActive est vrai
cn('px-4', 'px-6') // → 'px-6' (conflit : px-4 vs px-6, la dernière gagne)
Deux mots de trois lettres font tout le travail : clsx pour le conditionnel,
twMerge pour les conflits. cn n'est que la couture des deux.
Décortiquons les deux ingrédients, car ils résolvent deux problèmes différents. Le premier,
clsx, s'occupe de la composition conditionnelle. Il prend une liste de
valeurs hétéroclites — des chaînes, mais aussi des résultats d'expressions comme isActive
&& 'bg-primary' — et il les recolle en une seule chaîne, en ignorant tout ce qui est
faux. Quand isActive vaut false, l'expression false &&
'bg-primary' vaut false, et clsx le jette simplement. C'est exactement le même
motif d'affichage conditionnel que tu as rencontré ailleurs dans le guide avec condition
&& ... — ici appliqué aux classes de style.
Le second, tailwind-merge (alias twMerge), s'occupe des
conflits. Tailwind a beaucoup de classes qui touchent la même propriété :
px-4 et px-6 règlent toutes deux la marge horizontale. Si tu les laisses
côte à côte, le résultat dépend de subtilités d'ordre dans la feuille de style générée — fragile et
imprévisible. twMerge connaît les familles de classes Tailwind : il sait que
px-4 et px-6 sont en conflit, et il ne garde que la dernière
mentionnée. C'est ce qui rend la surcharge possible : tu peux poser un style par défaut, puis le faire
remplacer par une classe écrite plus loin dans la liste. Retiens bien cette règle —
la dernière classe gagne — elle reviendra plusieurs fois et c'est la clé pour
comprendre l'ordre des arguments du Button.
Sans twMerge, cn('px-4', 'px-6') donnerait la chaîne 'px-4
px-6' — les deux classes coexistent et c'est le navigateur (ou le moteur NativeWind) qui
tranche, de façon non garantie. Avec twMerge, le conflit est résolu avant
d'arriver au moteur : 'px-6'. La différence est invisible quand tout va bien, et
salvatrice le jour où une surcharge ne « prend » pas.
CVA : un catalogue de variantes au lieu de copier-coller
Voici le problème que CVA résout, et il vaut la peine de bien le poser. Un bouton n'existe presque
jamais en un seul exemplaire. Il y a le bouton principal (fond plein, couleur primaire), le bouton de
danger (rouge, pour « Supprimer »), le bouton secondaire, le bouton « contour » (transparent avec une
bordure), et ainsi de suite. Et chacun existe en plusieurs tailles. Sans outil, tu te retrouves à
recopier de longues chaînes de classes à chaque variante, et à les ajuster une par une quand le design
change. C'est de la duplication, et la duplication est le terreau des incohérences : un jour un bouton
de danger est rounded-md, un autre rounded-lg, parce qu'on a oublié un
copier-coller.
CVA (class-variance-authority) règle ça en te laissant décrire, une fois pour toutes, un catalogue de variantes. Tu déclares trois choses : une base (les classes communes à tous les boutons), des variants (les axes de variation, chacun avec ses options et les classes correspondantes), et des defaultVariants (les choix retenus si l'appelant ne précise rien). En échange, CVA te rend une fonction : tu l'appelles avec des noms, elle te rend la bonne chaîne de classes.
const buttonVariants = cva(
// 1. LA BASE : ces classes s'appliquent à TOUS les boutons, quelle que soit la variante.
'flex-row items-center justify-center gap-2 rounded-md',
{
// 2. LES AXES DE VARIATION. Chaque axe a des options ; chaque option a ses classes.
variants: {
variant: {
default: 'bg-primary active:bg-primary/90',
destructive: 'bg-destructive active:bg-destructive/90',
outline: 'border-border bg-background border',
// …secondary, ghost, link
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-12 px-6',
icon: 'h-10 w-10',
},
},
// 3. LES DÉFAUTS : si on ne précise ni variant ni size, on prend ceux-là.
defaultVariants: { variant: 'default', size: 'default' },
}
);
Une déclaration, six variantes de couleur × quatre tailles = vingt-quatre boutons possibles, tous cohérents, sans un seul copier-coller. Voilà ce que CVA t'achète.
Maintenant, comment lire un appel comme buttonVariants({ variant:
'destructive', size: 'lg' }) ? Mentalement, CVA fait une addition de chaînes : il part de la
base, il y ajoute les classes de l'option destructive de l'axe
variant, puis celles de l'option lg de l'axe size, et il te
rend le tout assemblé. Tu as donné deux noms ; tu reçois une longue chaîne de classes prête à
poser dans className. Si tu n'avais rien passé — buttonVariants({}) — CVA
serait allé chercher les defaultVariants et aurait construit le bouton
default / default. C'est tout le contrat : tu choisis des noms, CVA
calcule les classes.
Pense à CVA comme à un menu de restaurant. La base, c'est ce que tout plat
comprend (l'assiette, les couverts). Les variants, ce sont les choix : « viande ou
poisson » (axe variant), « petite ou grande faim » (axe size). Les
defaultVariants, c'est le « plat du jour » qu'on te sert si tu ne précises rien. Tu
commandes par des mots ; la cuisine assemble. Tu n'écris jamais la recette toi-même.
Le vrai buttonVariants de Halterofit, sans simplification
La version simplifiée ci-dessus capture l'idée, mais le vrai fichier va un cran plus loin, et c'est
instructif de voir pourquoi. Le vrai buttonVariants entoure ses chaînes d'un appel à
cn() et à Platform.select(...). Pourquoi ? Parce qu'Halterofit vise à la fois
le mobile et le web (via NativeWind), et certaines classes — survol de la souris, anneau de
focus au clavier, transitions — n'ont de sens que sur le web. Platform.select({ web: '...' })
ajoute ces classes uniquement sur la plateforme web, et cn() recolle proprement
le tout.
const buttonVariants = cva(
// La base est elle-même un cn() : des classes communes + des classes réservées au web.
cn(
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
Platform.select({
// Survol, focus clavier, transitions… : utile sur le web, ignoré sur mobile.
web: 'focus-visible:ring-ring/50 outline-none transition-all disabled:pointer-events-none',
})
),
{
variants: {
variant: {
// Chaque variante : une classe « active: » pour mobile, une « hover: » pour le web.
default: cn('bg-primary active:bg-primary/90 shadow-sm',
Platform.select({ web: 'hover:bg-primary/90' })),
destructive: cn('bg-destructive active:bg-destructive/90',
Platform.select({ web: 'hover:bg-destructive/90' })),
outline: cn('border-border bg-background active:bg-accent border',
Platform.select({ web: 'hover:bg-accent' })),
// …secondary, ghost, link
},
size: {
default: 'h-10 px-4 py-2 sm:h-9',
sm: 'h-9 gap-1.5 px-3 sm:h-8',
lg: 'h-12 px-6',
icon: 'h-10 w-10 sm:h-9 sm:w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
);
Même structure que la version simplifiée (base + variants + defaults), mais chaque chaîne est tissée
avec cn() et Platform.select pour servir mobile et web à partir d'une seule
source. Tu vois ici cn() et CVA travailler ensemble.
Note au passage la classe group dans la base, et les active: sur les
couleurs. group marque le bouton comme un « groupe parent » : ça permettra au
texte à l'intérieur de réagir quand le bouton est pressé (via des classes
group-active:). On en aura besoin dans une seconde, quand on parlera du texte. Garde
aussi en tête qu'il existe un second catalogue CVA juste à côté, buttonTextVariants,
construit exactement de la même façon mais pour la couleur du texte du bouton. Deux
catalogues parallèles : un pour le contenant, un pour le texte.
Le composant Button au complet, ligne par ligne
On a maintenant toutes les pièces. Voici le vrai composant Button, dans son intégralité,
et on va le lire avec la méthode du guide : les props en entrée, le JSX en sortie.
// Le type des props : tout ce que Pressable accepte, PLUS les variantes CVA.
type ButtonProps = React.ComponentProps<typeof Pressable> &
React.RefAttributes<typeof Pressable> &
VariantProps<typeof buttonVariants>; // ← variant et size, déduits du catalogue CVA
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
// 1. Le bouton DÉPOSE la bonne couleur de texte dans un contexte React.
// Tout <Text> à l'intérieur ira lire cette couleur tout seul.
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
// 2. cn() assemble TROIS sources de classes, dans un ordre qui compte :
className={cn(
props.disabled && 'opacity-50', // a) grisé SI désactivé (conditionnel)
buttonVariants({ variant, size }), // b) le style CVA selon variant/size
className // c) les classes de l'appelant, en DERNIER
)}
role="button"
{...props} // 3. on transmet TOUT le reste : onPress, disabled, children…
/>
</TextClassContext.Provider>
);
}
Quinze lignes qui réunissent tout le module : CVA (étape b), cn et son ordre (étape 2),
la transmission de props (étape 3), et le contexte React (étape 1). On déplie chaque point ci-dessous.
Les props en entrée. La signature { className, variant, size, ...props }
extrait trois props nommées et regroupe tout le reste dans props grâce à
...props (on y revient juste après). variant et size partent
alimenter les deux catalogues CVA. className est gardé de côté pour être assemblé en
dernier. Le type ButtonProps mérite un coup d'œil : il dit « ce composant accepte
tout ce qu'un Pressable accepte (grâce à
React.ComponentProps<typeof Pressable>), plus les variantes du catalogue
(grâce à VariantProps<typeof buttonVariants>) ». Ce VariantProps est
malin : il déduit automatiquement que variant peut valoir
'default' | 'destructive' | 'outline'… à partir du catalogue CVA. Tu ne maintiens pas une
liste de variantes à deux endroits : tu la déclares dans cva(...), et le type suit tout
seul.
Le JSX en sortie. Un <Pressable> (la brique « zone tactile » de
React Native), stylé par le cn(...), et emballé dans un
<TextClassContext.Provider>. C'est tout. Un composant de présentation classique :
props → JSX stylé.
Le contexte : pourquoi le bouton « dépose » une couleur de texte
La ligne la plus subtile est la première du JSX. Un bouton « danger » doit avoir un fond rouge
et un texte blanc lisible ; un bouton « lien » doit avoir un texte coloré et souligné. Or le
texte n'est pas écrit dans le bouton lui-même — il arrive via children, depuis
l'extérieur : <Button variant="destructive">Supprimer</Button>. Comment faire
pour que ce <Text> « Supprimer », que le bouton ne contrôle pas, prenne quand même
la bonne couleur ?
La réponse, c'est le contexte React, exactement le mécanisme expliqué dans le module
sur useContext, useMemo et memo. Le bouton calcule la bonne couleur de texte avec son second catalogue
(buttonTextVariants({ variant, size })) et la dépose dans
TextClassContext via le Provider. Le composant <Text> de
l'app, lui, est programmé pour lire ce contexte : où qu'il se trouve à l'intérieur du bouton,
il récupère la couleur déposée et se l'applique, sans qu'on ait à la lui passer en prop. Le
contexte sert ici exactement à ce pour quoi il existe : faire descendre une information à travers
plusieurs niveaux de composants sans la passer de main en main. Revois le module sur le contexte si la
mécanique Provider / lecture te semble floue — c'est le même schéma, appliqué à un cas
très concret.
Grâce à ce Button, tout l'app écrit simplement <Button variant="destructive"
size="lg">Supprimer</Button> et obtient, partout, un bouton parfaitement cohérent : la
bonne couleur de fond, la bonne taille, et la bonne couleur de texte propagée par le
contexte. Le style vit à un seul endroit — ce fichier — et le reste de l'app n'a
qu'à nommer ce qu'il veut. Ajoute une variante un jour ? Tu la déclares ici une fois, et elle est
disponible dans toute l'app, type TypeScript compris.
La transmission des props et l'art de l'ordre dans cn()
Deux détails du Button méritent qu'on s'y attarde, parce qu'ils reviennent dans presque
tous les composants bien écrits : {...props} et l'ordre des arguments de cn.
La transmission, {...props}. C'est l'opérateur de « répartition »
(spread). Dans la signature, ...props a collecté toutes les props non
nommées dans un objet. Sur le <Pressable>, {...props} les
réétale : il transmet d'un coup onPress, disabled,
children, accessibilityLabel et tout le reste, sans que le Button ait à les
énumérer un par un. C'est ce qui rend le composant transparent : il ajoute son style et son
contexte, mais pour tout le reste il se comporte comme un vrai Pressable. Sans cette
ligne, il faudrait redéclarer manuellement chaque prop de Pressable qu'on veut laisser
passer — fastidieux et fragile.
L'ordre dans cn. Souviens-toi de la règle d'or de twMerge :
en cas de conflit, la dernière classe gagne. Regarde alors l'ordre choisi par le Button : le
style conditionnel d'abord, puis le style CVA, puis className en dernier.
Ce n'est pas un hasard, c'est une décision d'ergonomie. En plaçant className tout à la
fin, le composant permet à celui qui l'utilise de surcharger un défaut si besoin. Tu veux un
bouton « danger » mais exceptionnellement avec une marge différente ? <Button
variant="destructive" className="px-8"> : ton px-8, venu en dernier, l'emporte
sur le px-4 de la variante. Le composant propose ses défauts, l'appelant
peut disposer. Inverse l'ordre, et la surcharge devient impossible : les classes de
l'appelant seraient écrasées par celles de CVA. L'ordre n'est pas cosmétique — c'est le contrat
d'extension du composant.
Le même ... joue deux rôles opposés selon l'endroit. Dans la signature
{ variant, ...props }, il collecte ce qui reste dans un objet. Sur la balise
{...props}, il réétale cet objet en props individuelles. C'est la même syntaxe
« rest/spread » que tu utilises déjà sur les tableaux et les objets en JavaScript courant — ici au
service du passage de props.
La cohérence imposée : des règles ESLint maison
Tout ce bel édifice — design system, cn, CVA — ne tient que si tout le monde joue
le jeu. Le risque est connu : un jour, sous la pression, quelqu'un (peut-être toi) écrit une couleur
« en dur » dans un écran, du genre className="bg-[#FF0000]" ou un code hexadécimal dans
un style, parce que « c'est juste pour aller vite ». Une fois, ce n'est rien. Cent fois, le design
system est contourné et l'app redevient un patchwork incohérent. La bonne volonté ne suffit pas à
l'échelle d'un projet.
Halterofit traite ce risque par l'outillage plutôt que par la discipline. Le projet
embarque des règles ESLint maison — des règles de lint écrites
spécifiquement pour ce dépôt — qui interdisent d'écrire des couleurs ou des polices en dur.
Si tu tapes une valeur hexadécimale de couleur dans un fichier, ESLint te le signale (souvent en
rouge directement dans l'éditeur, et en erreur bloquante à la validation). Le message te pousse vers la
bonne pratique : « utilise une classe du thème (bg-primary) ou un composant du design
system ». La machine devient ainsi la gardienne de la cohérence : impossible de dévier sans que
l'outil le remarque.
// ❌ REFUSÉ : couleur en dur. ESLint signale une erreur.
<View className="bg-[#FF0000]" />
<Text style={{ color: '#1E40AF' }} />
// ✅ ACCEPTÉ : on passe par le thème / le design system.
<View className="bg-destructive" /> // couleur sémantique du thème
<Button variant="destructive" /> // mieux encore : un composant qui existe déjà
La règle ne dit pas « sois cohérent », elle force la cohérence en refusant les valeurs en dur. C'est l'outillage qui protège l'architecture.
On a rencontré cette philosophie dans le module sur l'outillage : ESLint, le formatage, les contrôles automatiques ne sont pas là pour t'embêter, mais pour encoder dans la machine les règles que l'équipe s'est données. Une règle maison comme « pas de couleur en dur » transforme une bonne intention en garantie. C'est exactement pour ça que le design system tient dans le temps.
Lire un appel de Button dans la nature
Mettons bout à bout tout ce qu'on a vu, du point de vue d'un écran qui utilise le bouton, pas de celui qui le fabrique. Voici à quoi ressemble un appel typique, et comment le lire de gauche à droite.
<Button variant="destructive" size="lg" onPress={supprimerSeance} disabled={enCours}>
Supprimer la séance
</Button>
Un appel complet, tel qu'on en trouve dans un écran.
Décodons. variant="destructive" et size="lg" partent dans les deux catalogues
CVA : ils déterminent le fond rouge, la grande taille, et — via le contexte — la couleur de texte.
onPress={supprimerSeance} et disabled={enCours} ne sont pas des props nommées
du Button : elles tombent donc dans ...props et sont transmises au
Pressable par {...props}. Comme disabled est aussi lu à
l'intérieur (props.disabled && 'opacity-50'), le bouton se grise automatiquement
quand enCours est vrai. Enfin, Supprimer la séance est le
children : il arrive dans le Pressable, et son <Text> lit
la couleur déposée dans le contexte. Tu n'as écrit qu'une ligne lisible ; sous le capot, CVA,
cn, la transmission et le contexte ont tous joué leur partition. Voilà la
récompense d'un bon design system : la complexité est rangée dans la brique, et l'usage reste simple.
Regarde l'ordre d'assemblage dans le cn du Button :
className={cn(
props.disabled && 'opacity-50',
buttonVariants({ variant, size }),
className // ◀── les classes passées de l'extérieur, en DERNIER
)}
Questions : (1) Pourquoi className est-il placé en dernier ?
(2) Que produit props.disabled && 'opacity-50' quand le bouton n'est PAS
désactivé ? (3) Si quelqu'un écrit <Button variant="default"
className="bg-destructive">, de quelle couleur sera le fond, et pourquoi ?
Voir le corrigé
(1) Parce que dans cn (via twMerge), en cas de conflit,
la dernière classe gagne. En plaçant className en dernier, on permet
à l'appelant de surcharger un défaut du composant si besoin. C'est un choix d'ergonomie :
le composant propose, l'appelant peut disposer.
(2) Si disabled vaut false, l'expression false
&& 'opacity-50' vaut false, et cn (grâce à clsx)
ignore les valeurs fausses. Donc aucune classe d'opacité n'est ajoutée. C'est le même motif
conditionnel condition && ... qu'on retrouve partout dans le guide.
(3) Le fond sera rouge (bg-destructive). La variante
default a posé bg-primary, mais le className="bg-destructive"
de l'appelant arrive après dans cn : bg-primary et
bg-destructive sont en conflit (même propriété : la couleur de fond), et la dernière
gagne. C'est précisément le pouvoir de surcharge décrit en (1) — utile, mais à manier avec
parcimonie pour ne pas trahir l'esprit du design system.
1. Qu'est-ce que NativeWind, en une phrase ?
Une façon d'écrire le style avec des classes utilitaires (Tailwind) dans className, que NativeWind traduit en style natif pour React Native.
2. Quel problème CVA résout-il, et avec quelles trois pièces ?
Il évite de recopier le style de chaque variante. On déclare une base (classes communes), des variants (les axes, ex. variant et size) et des defaultVariants (les choix par défaut), puis on demande les classes par leur nom : buttonVariants({ variant: 'outline' }).
3. Dans cn, à quoi servent clsx et tailwind-merge, respectivement ?
clsx assemble les classes conditionnelles en jetant les valeurs fausses ; tailwind-merge résout les conflits Tailwind en gardant la dernière classe.
4. Comment le texte « Supprimer » d'un <Button variant="destructive"> obtient-il sa couleur, alors que le bouton ne l'écrit pas ?
Le bouton dépose la couleur dans TextClassContext (un contexte React, vu dans le module sur useContext/useMemo/memo) ; le composant <Text> à l'intérieur lit ce contexte et s'applique la couleur tout seul, sans qu'on la lui passe en prop.
5. Pourquoi placer className en dernier dans le cn du Button ?
Pour permettre la surcharge : comme la dernière classe gagne, les classes de l'appelant peuvent remplacer un défaut du composant. Le composant propose, l'appelant dispose.
On compose des petites briques (un design system dans components/ui/)
plutôt qu'un monolithe configurable ; children rend les contenants génériques. Le style
est utility-first : des classes NativeWind dans className, pas de CSS
séparé. cn() assemble (clsx, conditionnel) et résout les conflits (tailwind-merge,
dernière gagne). CVA = base + variants + defaultVariants : tu nommes, il calcule les
classes. Le Button réunit tout : CVA pour le style, cn pour l'assemblage,
{...props} pour la transmission, et le contexte pour propager la couleur
de texte. L'ordre dans cn autorise la surcharge ; des règles ESLint maison
interdisent les valeurs en dur pour garder l'app cohérente.
Deux réflexes à corriger. Un : styliser un écran « à la main », avec des couleurs ou
tailles écrites en dur, au lieu de réutiliser les composants de components/ui/ et les
classes du thème — ESLint te le refusera, et c'est tant mieux. Deux : recréer un
composant qui existe déjà (un énième « bouton » local) parce qu'on n'a pas regardé le design system.
Avant d'écrire une brique visuelle, demande-toi toujours : « existe-t-il déjà ? ». La cohérence
vient de la réutilisation, pas de la réinvention.
Rien de tout ça n'est spécifique au mobile. Tailwind, cn (clsx + tailwind-merge) et CVA
sont omniprésents sur le web React moderne — souvent à l'identique, mot pour mot. Le découpage en
petites briques composables et la distinction présentation/conteneur sont universels en front. Ce que
tu apprends ici se transpose directement à un projet React web, et au futur apps/web de
Halterofit qui partagera cette même façon de penser le style.