Tous les projets front commencent pareil. Un dossier components, quelques
fichiers, tout va bien. Six mois plus tard il contient quatre-vingts fichiers,
trois boutons différents, deux modales qui font la même chose, et personne
n'ose supprimer Card2.tsx parce que personne ne sait qui l'importe.
Le problème n'est pas le désordre. Le désordre est un symptôme. Le problème est qu'aucune règle ne dit où va un composant ni ce qu'il a le droit de savoir. Sans ces deux règles, chaque développeur invente les siennes, et elles ne sont jamais les mêmes.
L'atomic design, formulé par Brad Frost, répond exactement à ces deux questions. Ce n'est pas une arborescence de dossiers — c'est une discipline dont les dossiers ne sont que la trace visible.
Les cinq niveaux
L'idée : partir du plus petit élément indivisible et composer vers le haut. Chaque niveau a une règle d'admission stricte, et c'est cette rigueur qui fait tout le travail. Un niveau sans règle d'admission est juste un dossier.
Atomes
Les briques indivisibles. Les décomposer leur ferait perdre leur fonction :
Button, Input, Label, Badge, Icon, Spinner.
Un atome ne connaît ni ton API, ni ton store, ni ta logique métier. Il reçoit
des props, il rend du HTML. C'est tout — et c'est précisément ce qui le rend
réutilisable partout sans conditions.
Règle d'admission — un atome n'a aucune logique métier et ne connaît aucune donnée de l'application. S'il appelle une API ou lit un store, ce n'est pas un atome.
Le piège classique à ce niveau est le bouton qui « sait » qu'il sert à
soumettre un formulaire de connexion. Il ne doit pas le savoir : il reçoit un
onClick, un libellé et un état disabled.
Molécules
Un groupe d'atomes soudés pour accomplir une tâche précise. Un champ de
recherche, c'est un Label + un Input + un Button : trois atomes, une
intention.
Une molécule peut avoir un état local — la valeur du champ, l'ouverture d'un menu — mais cet état ne concerne qu'elle. Elle ne sait toujours pas d'où viennent les données.
Règle d'admission — une molécule fait une seule chose. Si tu as besoin de « et » pour la décrire, c'est un organisme.
Organismes
Des sections complètes et autonomes de l'interface : un en-tête de site, une grille de produits, un formulaire d'inscription. Un organisme peut aller chercher ses données, gérer des effets, connaître le contexte applicatif.
Règle d'admission — un organisme est identifiable par un non-technicien : « le header », « le panier », « la liste des factures ».
C'est le niveau le plus difficile à cadrer, parce que c'est celui où la tentation de tout mettre est la plus forte. Un organisme de 400 lignes n'est pas un organisme, c'est une page mal rangée.
Templates
On abandonne ici la métaphore chimique. Un template est une mise en page : il place les organismes, définit les zones, les espacements et le comportement responsive, sans contenu réel. C'est le squelette.
Un template ne devrait recevoir que des children et des slots. S'il reçoit un
objet user, c'est qu'il fait déjà le travail d'une page.
Pages
L'instance finale. On injecte les vraies données — API, CMS, base — dans le template. C'est le seul niveau où l'on découvre que le titre de trois mots du maquettage fait en réalité 140 caractères, et que la liste « quelques éléments » en compte 2 000.
C'est aussi le niveau où l'on teste les cas limites : liste vide, erreur réseau, chargement, permissions insuffisantes. Ces états font partie du design, pas des finitions.
Le traduire dans l'App Router
L'App Router ajoute une dimension que Brad Frost n'avait pas : la frontière serveur / client. Elle se superpose remarquablement bien aux niveaux.
| Niveau | Rendu | Pourquoi |
|---|---|---|
| Atomes | Serveur ou client | Pas d'état : "use client" seulement s'ils ont un onClick |
| Molécules | Souvent client | Elles portent l'interaction locale |
| Organismes | Mixte | Coque serveur, îlots clients à l'intérieur |
| Templates | Serveur | Pure mise en page |
| Pages | Serveur | Le page.tsx récupère les données |
La règle pratique tient en une phrase : "use client" se pose le plus bas
possible dans l'arbre. Un "use client" sur un template fait basculer tous
ses descendants côté client et annule l'intérêt des Server Components.
La coque serveur et l'îlot client
Le motif le plus utile en pratique : un organisme rendu côté serveur, qui délègue la seule partie interactive à un composant client.
// Server Component : il récupère et met en forme. Zéro JavaScript envoyé.
import { getInvoices } from "@/lib/invoices";
import { InvoiceRowActions } from "./invoice-row-actions";
export async function InvoiceTable({ clientId }: { clientId: string }) {
const invoices = await getInvoices(clientId);
return (
<table>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>{invoice.reference}</td>
<td>{invoice.formattedTotal}</td>
{/* Seule cette cellule embarque du JavaScript. */}
<td>
<InvoiceRowActions id={invoice.id} status={invoice.status} />
</td>
</tr>
))}
</tbody>
</table>
);
}"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function InvoiceRowActions({ id, status }: Props) {
const [busy, setBusy] = useState(false);
// ...
return <Button disabled={busy}>Relancer</Button>;
}Le tableau peut compter 500 lignes : le HTML est généré côté serveur, et seul le composant d'actions est hydraté. La différence sur l'INP est massive, et elle ne coûte qu'un fichier de plus.
Le piège du contexte
Un Context React est forcément client. Si tu enveloppes ton application dans
quatre providers, tout devient client, quelle que soit ta discipline sur
"use client".
Le contournement : un composant Providers client, monté dans le layout
racine, qui reçoit children en props. React rend les enfants côté serveur
et les passe au provider client sans les faire basculer.
import { Providers } from "@/components/providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body>
{/* children reste rendu côté serveur : le provider ne le contamine pas. */}
<Providers>{children}</Providers>
</body>
</html>
);
}Le cas shadcn/ui
C'est là que la théorie rencontre la réalité, parce que shadcn installe tout
dans components/ui et ne s'intéresse pas à ta taxonomie.
Deux camps s'affrontent : garder atoms/, molecules/, organisms/ en
strict, ou tout aplatir. Les deux ont tort à leur manière — le strict noie les
imports sous les dossiers et provoque des débats d'une heure pour savoir si un
Dropdown est une molécule ou un organisme ; le plat reproduit le problème
initial.
L'approche hybride
Traite components/ui comme ta bibliothèque d'atomes. C'est exactement ce
qu'elle est : des primitives sans logique métier, que tu possèdes et peux
modifier puisque le code est chez toi.
src/components/
├── ui/ # atomes — shadcn, non métier, jamais de fetch
│ ├── button.tsx
│ ├── input.tsx
│ └── badge.tsx
├── molecules/ # compositions réutilisables, une intention chacune
│ ├── search-field.tsx
│ └── stat-card.tsx
├── organisms/ # sections identifiables du produit
│ ├── site-header.tsx
│ └── invoice-table.tsx
└── templates/ # mises en page sans données
└── dashboard.tsxCertains composants shadcn sont déjà des molécules déguisées — Card,
Accordion, Sheet composent plusieurs primitives. Ne les déplace pas : la
cohérence des chemins d'import vaut mieux qu'une taxonomie parfaite, et tu veux
que npx shadcn@latest add continue de fonctionner sans réorganisation
manuelle. Ce qui compte, c'est la règle d'admission, pas le tiroir.
| Critère | Strict (Brad Frost) | Hybride |
|---|---|---|
| Dossiers | atoms/ molecules/ organisms/ |
ui/ + 3 dossiers métier |
| Complexité | Élevée | Faible |
| Compatible shadcn | Non, il faut tout déplacer | Oui, add fonctionne tel quel |
| Cible | Design systems multi-produits | Applications et SaaS |
Composer plutôt qu'accumuler les props
L'atomic design échoue le plus souvent par le bas : un atome qui grossit jusqu'à devenir ingérable. Le symptôme est toujours le même — une avalanche de props booléennes.
// ❌ Chaque nouveau besoin ajoute un booléen. Combinatoire ingérable :
// que fait `isPrimary` avec `isGhost` ? Personne ne sait, et le composant non plus.
<Button isPrimary isLarge hasIcon isLoading isFullWidth />Deux réflexes corrigent ça.
Un, des variantes nommées plutôt que des booléens. class-variance-authority
formalise les combinaisons valides et rend les autres impossibles à écrire.
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center font-medium transition-colors",
{
variants: {
variant: {
solid: "bg-ink text-paper hover:bg-ink/85",
outline: "border border-ink text-ink hover:bg-ink hover:text-paper",
ghost: "text-ink hover:bg-black/5",
},
size: { sm: "h-8 px-3 text-sm", md: "h-10 px-4", lg: "h-12 px-6" },
},
defaultVariants: { variant: "solid", size: "md" },
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}Deux, la composition plutôt que la configuration. Quand un composant a
besoin d'un en-tête, d'un pied et d'un corps, ne lui passe pas trois props
ReactNode : expose des sous-composants.
// ❌ Configuration : chaque nouveau besoin est une prop de plus.
<Card title="Factures" footer={<Button />} badge="3" />
// ✅ Composition : l'appelant assemble ce dont il a besoin.
<Card>
<Card.Header>
Factures <Badge>3</Badge>
</Card.Header>
<Card.Body>…</Card.Body>
<Card.Footer>
<Button>Exporter</Button>
</Card.Footer>
</Card>Laisser une porte de sortie
Un composant totalement fermé finit toujours par être dupliqué le jour où il
manque un cas. Accepter className et le fusionner avec tailwind-merge
suffit à éviter la copie sauvage.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
// twMerge résout les conflits : un `px-6` passé par l'appelant
// remplace le `px-4` du composant au lieu de cohabiter avec lui.
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}L'erreur numéro un : des composants trop intelligents
Ce n'est pas l'arborescence qui tue un projet, c'est la logique qui descend là où elle ne devrait pas. La règle est simple, et elle est plus importante que tout le reste de cet article.
Atomes et molécules sont bêtes. Ils reçoivent des props, ils émettent des
callbacks. Jamais de useAuth, jamais de fetch, jamais de lecture de store.
Organismes et pages sont intelligents. C'est là que vivent les données, le store et les requêtes.
// ✅ Bête : testable, réutilisable, sans dépendance au contexte.
export function UserCard({
name,
role,
onSelect,
}: {
name: string;
role: string;
onSelect: () => void;
}) {
return (
<button onClick={onSelect} className="...">
<span>{name}</span>
<span>{role}</span>
</button>
);
}// ✅ Intelligent : il connaît la source de données et les décisions.
"use client";
import { useQuery } from "@tanstack/react-query";
import { UserCard } from "@/components/molecules/user-card";
export function UserList({ onPick }: { onPick: (id: string) => void }) {
const { data = [] } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
return data.map((user) => (
<UserCard key={user.id} {...user} onSelect={() => onPick(user.id)} />
));
}Le test qui ne trompe pas : est-ce que je peux rendre ce composant dans un fichier de test sans monter un provider ? Si oui, c'est un atome ou une molécule. Si non, la logique est descendue trop bas.
Nommer, sinon dupliquer
Le mode d'échec le plus fréquent n'est pas le mauvais rangement : c'est
SearchBar créé par quelqu'un qui n'avait pas trouvé SearchField.
Trois conventions suffisent à l'éviter :
- Nommer par le rôle, pas par l'apparence.
DangerButtondevient faux le jour où le rouge change de sens ;DestructiveActionreste vrai. - Nommer par le domaine au-dessus des molécules.
InvoiceTable,InvoiceFilters,InvoiceRowActionsse rangent naturellement ensemble et se trouvent d'une recherche. - Un composant, un fichier, le même nom.
invoice-table.tsxexporteInvoiceTable. Sans exception, pour que la recherche par fichier fonctionne.
Migrer un projet qui a déjà dérivé
Personne ne réorganise 80 composants un vendredi après-midi. La migration progressive marche bien mieux, et elle tient en quatre temps :
- Créer les dossiers vides et geler l'ancien. Rien ne bouge, mais tout nouveau composant naît au bon endroit. La dérive s'arrête immédiatement.
- Remonter les atomes en premier. Ils n'ont pas de dépendances : les déplacer ne casse que des imports, ce qu'un éditeur corrige tout seul.
- Déplacer au fil des tickets. Un composant touché est un composant qu'on range. La couverture progresse là où le code vit vraiment.
- Supprimer ce que personne n'importe.
npx knipounpx ts-prunelistent le code mort en quelques secondes ; c'est généralement 10 à 20 % du dossier.
L'ordre compte : commencer par les organismes oblige à déplacer leurs dépendances en même temps, et la migration devient un gros commit risqué.
Documenter, sinon rien
Un système non documenté est un système que personne ne trouve, donc que tout le monde recode.
Storybook reste l'outil le plus direct : chaque molécule a une story, chaque
état est visible, et les designers disposent d'un catalogue de ce qui existe
réellement plutôt que de ce qui a été maquetté. Un DatePicker avec ses
états vide / rempli / désactivé / erreur sur une seule page vaut mieux que
n'importe quelle documentation écrite.
Si Storybook est trop lourd pour ton projet, une route /design-system
accessible uniquement en développement fait déjà 80 % du travail — et coûte une
après-midi.
Les signaux qui disent que ça marche
Trois indicateurs simples, à regarder tous les trimestres :
- Le temps pour ajouter un écran. S'il diminue, le système paie. S'il augmente, la structure est devenue un obstacle plutôt qu'un appui.
- Le nombre de composants pour une même intention. Un seul bouton, un seul champ de recherche. Deux, c'est un signal ; trois, c'est une dette.
- La part de code client.
next buildaffiche le poids du JavaScript par route. S'il grossit sans nouvelle fonctionnalité, un"use client"est remonté trop haut quelque part.
En résumé
L'atomic design n'est pas une arborescence de dossiers, c'est une discipline sur ce que chaque niveau a le droit de savoir. Les dossiers ne sont que la trace visible de cette discipline.
Cinq règles suffisent à en tirer l'essentiel :
- Un composant sans logique métier ne descend jamais vers une source de données.
"use client"se pose le plus bas possible dans l'arbre.- On compose avec des sous-composants plutôt qu'on accumule des props booléennes.
- On nomme par le rôle et le domaine, jamais par l'apparence.
- Ce qui n'est pas documenté sera recodé.
Le reste — le nom exact des dossiers, la frontière entre molécule et organisme — est négociable, et le débat ne mérite pas plus de dix minutes. Ces cinq règles-là, elles, ne sont pas négociables.