Tailwind CSS
Bloc Laravel · séance 5 · ~60 min
Depuis le chapitre 9, vos pages s'affichent avec le style par défaut du navigateur : texte noir sur fond blanc, titres en gras, liens bleus soulignés. Dans ce chapitre, vous donnez à une application une interface propre et moderne, sans écrire de feuille de style. Les exemples viennent de la Ludothèque des Tilleuls, qui prête des jeux de société à ses membres. Ce chapitre se lit : vous pratiquerez au chapitre 14, dans le dépôt tailwind-26, qui contient les pages de la ludothèque encore sans style.
À la fin de ce chapitre, vous serez capables de :
- expliquer ce qu'est une classe utilitaire, et comparer une page stylée avec Tailwind à une page stylée par une feuille de style écrite à la main,
- charger le CSS et le JavaScript de Vite dans un layout, et choisir entre
npm run devetnpm run build, - composer une page avec les classes d'espacement, de typographie, de couleur, de bordure, de Flex et de Grid, adaptée à la largeur de l'écran et aux états d'un bouton,
- regrouper ces classes dans des composants Blade à variantes, sans construire de nom de classe à partir d'une variable,
- trouver une classe dans la documentation, et écrire un court script dans
resources/js/app.js.
1. Une carte, deux façons d'écrire le style
Le catalogue affiche chaque jeu dans une carte : un cadre blanc, un titre, une ligne grise. Voici cette carte écrite comme dans un cours de CSS, avec une classe inventée et une feuille de style :
.game-card {
padding: 1.5rem;
background-color: white;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.game-card h2 {
font-size: 1.125rem;
font-weight: 600;
}
.game-card p {
margin-top: 0.5rem;
color: #52525b;
}
<article class="game-card">
<h2>Les Comptoirs de Safran</h2>
<p>2 à 4 joueurs · 90 min</p>
</article>
Avec Tailwind CSS, la même carte s'écrit sans feuille de style. Chaque balise reçoit une liste de classes déjà prêtes :
<article class="rounded-lg border border-zinc-200 bg-white p-6 shadow-xs">
<h2 class="text-lg font-semibold">Les Comptoirs de Safran</h2>
<p class="mt-2 text-zinc-600">2 à 4 joueurs · 90 min</p>
</article>
Chaque classe applique une seule propriété CSS. p-6 règle la marge intérieure, mt-2 la marge au-dessus du paragraphe, text-lg la taille du texte. Une classe de ce genre s'appelle une classe utilitaire. Tailwind lit vos vues, y trouve ces noms, et écrit pour chacun une règle dans le fichier CSS qu'il produit. Voici trois de ces règles :
.p-6{padding:calc(var(--spacing) * 6)}
.mt-2{margin-top:calc(var(--spacing) * 2)}
.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}
La variable --spacing vaut 0.25rem. p-6 donne donc 6 × 0,25 rem, soit le 1.5rem de la feuille de style. Tailwind propose ainsi une échelle de valeurs, et vous choisissez dans cette échelle au lieu d'écrire des nombres.
Cette façon de travailler s'appelle utility-first : vous partez des classes utilitaires, et vous n'écrivez du CSS que pour ce qu'elles ne couvrent pas. La liste de classes est longue, mais vous la lisez à l'endroit où elle s'applique, sans ouvrir un second fichier.
La longueur ne se répète pas d'une page à l'autre si la carte devient un composant Blade, comme au chapitre 9. Vous écrivez la liste une fois, dans components/card.blade.php, et chaque page écrit <x-card>.

Le code qui sent mauvais
En binôme, cinq minutes. Une ludothèque a stylé son site avec une feuille de style de 900 lignes, écrite à la main. Elle contient ces règles :
.loan-card {
padding: 1rem;
border-left: 4px solid #b91c1c;
}
.loan-card .late {
color: #b91c1c;
font-weight: 700;
}
La page des prêts a été réécrite le mois dernier. Pouvez-vous supprimer ces deux règles ? Comment en êtes-vous sûrs ?
2. Brancher Tailwind dans le layout
Ce que laravel new a déjà installé
Tailwind est déjà installé dans tout projet créé avec laravel new, comme premier-test-laravel. Le fichier package.json contient les paquets tailwindcss et @tailwindcss/vite. Le fichier vite.config.js, vu au chapitre 6, charge l'extension de Tailwind pour Vite. Ouvrez maintenant resources/css/app.css :
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
@import 'tailwindcss';charge Tailwind. Cette ligne apporte une remise à zéro des styles du navigateur, les variables de l'échelle, comme--spacing, et la capacité de produire les classes utilitaires.- Les deux
@sourceajoutent des dossiers à lire. Tailwind lit déjà tous les fichiers du projet, sauf ceux que Git ignore, commevendor/etstorage/framework/views/. La première ligne ajoute les templates de pagination de Laravel, rangés dansvendor/. La seconde ajoute les vues compilées, que Blade écrit dansstorage/framework/views/. @themedéclare des variables de thème, des variables CSS qui créent des classes. Ici,--font-sanschoisit la police Instrument Sans pour la classefont-sans, que Tailwind applique à toute la page.
Tailwind lit vos fichiers comme du texte. Il n'exécute ni PHP ni JavaScript : il cherche dans chaque fichier des mots qui ressemblent à des noms de classes. Vos vues de resources/views/ et votre JavaScript de resources/js/ sont donc lus sans aucune ligne à ajouter.
La seule ligne à ajouter : @vite dans le <head>
Le fichier app.css est une source. Le navigateur ne le charge pas tel quel : il faut lui donner le fichier que Vite produit à partir de lui. La directive Blade @vite écrit les balises qui chargent ce fichier. Dans le layout de la ludothèque, components/layouts/app.blade.php, ajoutez-la sous la balise <title>, avec la directive @fonts :
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title }} · Ludothèque des Tilleuls</title>
@fonts
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
@vite([...])reçoit les deux sources quevite.config.jsdéclare, le CSS et le JavaScript, et écrit une balise pour chacune.@fontscharge la police Instrument Sans.vite.config.jsla télécharge, et@themela choisit. Sans@fonts, le navigateur utilise la police suivante de la liste, celle du système. La page d'accueil de Laravel,welcome.blade.php, contient les mêmes deux directives.- La balise
<meta name="viewport">, déjà présente, demande au téléphone d'afficher la page à sa vraie largeur. Sans elle, il dessine la page sur une largeur d'environ 1 000 pixels, puis la réduit. Le texte devient minuscule, et la page prend la mise en page prévue pour un écran large.
Ouvrez le catalogue, puis affichez son code source :
<title>Catalogue · Ludothèque des Tilleuls</title>
…
<link rel="preload" as="style" href="http://tailwind-26.test/build/assets/app-D32E-bvF.css" /><link rel="modulepreload" as="script" href="http://tailwind-26.test/build/assets/app-C5TGVq_p.js" /><link rel="stylesheet" href="http://tailwind-26.test/build/assets/app-D32E-bvF.css" /><script type="module" src="http://tailwind-26.test/build/assets/app-C5TGVq_p.js"></script></head>
@fonts a écrit les lignes masquées par …, qui chargent la police. @vite a écrit une balise <link> pour le CSS et une balise <script> pour le JavaScript. Les deux fichiers viennent de public/build/assets/. La suite de caractères dans leur nom, comme D32E-bvF, change à chaque fois que leur contenu change. Le navigateur ne peut donc pas garder en mémoire une ancienne version.
npm run build et npm run dev
Au chapitre 4, laravel new a lancé npm run build. Cette commande lit les sources une fois, écrit le résultat dans public/build/, puis s'arrête :
npm run build
> build
> vite build
vite v8.3.2 building client environment for production...
…
public/build/manifest.json 1.47 kB │ gzip: 0.33 kB
…
public/build/assets/app-D32E-bvF.css 29.46 kB │ gzip: 6.28 kB
public/build/assets/app-C5TGVq_p.js 0.24 kB │ gzip: 0.17 kB
✓ built in 801ms
Le fichier CSS ne contient que les classes trouvées dans vos fichiers au moment du build. Si vous écrivez ensuite dans une vue une classe qu'aucun fichier n'utilisait, comme bg-red-50, elle n'a aucun effet : aucune règle ne lui correspond dans public/build/. Pendant que vous travaillez, laissez tourner npm run dev dans un terminal :
npm run dev
> dev
> vite
VITE v8.3.2 ready in 319 ms
…
LARAVEL v13.34.0 plugin v3.2.0
La commande ne rend pas la main. Vite surveille vos fichiers, ajoute les règles des nouvelles classes et recharge la page ouverte dans le navigateur. Le code source de la page montre alors d'autres balises :
<script type="module" src="http://…/@vite/client"></script><link rel="stylesheet" href="http://…/resources/css/app.css" /><script type="module" src="http://…/resources/js/app.js"></script></head>
Pendant que npm run dev tourne, Vite écrit son adresse dans le fichier public/hot. @vite y lit cette adresse, et les balises désignent Vite au lieu de public/build/. Quand vous arrêtez la commande avec Ctrl+C, le fichier public/hot disparaît, et @vite revient aux fichiers de public/build/.
Vous avez vu au chapitre 4 que composer run dev lance plusieurs programmes, dont vite : quatre sous macOS et Linux, trois sous Windows. Ce programme est npm run dev : si composer run dev tourne déjà, vous n'avez rien d'autre à lancer.
Lancez npm run build avant de livrer un projet, ou quand vous arrêtez de travailler. La page reste alors stylée sans Vite.
Deux messages à reconnaître
Dans un projet cloné avec Git, comme eloquent-26 au chapitre 10, le dossier public/build/ n'existe pas : il est exclu de Git. Une page qui utilise @vite renvoie alors une erreur 500 :
Vite manifest not found at: …/public/build/manifest.json
Le manifeste est le fichier qui associe chaque source à son fichier produit, comme app.css à app-D32E-bvF.css. Lancez npm install, puis npm run build ou npm run dev.
Astuce
Les deux commandes affichent aussi un avertissement jaune sur le paquet fontaine. Il concerne l'affichage de la police pendant son chargement, et vous pouvez l'ignorer. Si une page perd tous ses styles après l'arrêt de npm run dev, vérifiez si le fichier public/hot existe encore, et supprimez-le.
3. Les familles de classes
Les tableaux de cette section rangent par famille les classes que vous utiliserez au chapitre 14. Ils ne sont pas à apprendre par cœur : la documentation de Tailwind et l'extension Tailwind CSS IntelliSense de VS Code vous les rappellent au moment d'écrire (section 7).
Espacement et tailles
Chaque page de la ludothèque place son contenu dans un bloc centré, de largeur limitée :
<main class="mx-auto max-w-6xl px-4 py-8">
<h1 class="text-3xl font-semibold tracking-tight">Catalogue</h1>
<div class="mt-6 space-y-4">
…
</div>
</main>
max-w-6xl limite la largeur de <main> à 72 rem, et mx-auto partage l'espace restant à gauche et à droite : le bloc est centré. px-4 évite que le texte touche les bords sur un téléphone. space-y-4 écarte chaque enfant du <div> de son voisin du dessus.
La marge intérieure, padding en CSS, est l'espace entre le bord d'un élément et son contenu. La marge extérieure, margin en CSS, est l'espace entre l'élément et ses voisins. Le nombre d'une classe d'espacement est un multiple de 0.25rem, soit 4 pixels avec la taille de texte par défaut du navigateur.
| Classe | Ce qu'elle fait | Exemple |
|---|---|---|
p-{n} |
marge intérieure sur les quatre côtés | p-6, 1,5 rem |
px-{n}, py-{n}, pt-{n} |
marge intérieure à gauche et à droite (x), en haut et en bas (y), en haut seulement (t) |
px-4 py-2 |
mt-{n}, mb-{n} |
marge extérieure en haut, en bas | mt-6 |
mx-auto |
centre un bloc de largeur limitée | mx-auto max-w-6xl |
space-y-{n} |
espace vertical entre les enfants | space-y-4 |
w-full, max-w-{taille} |
largeur de 100 %, largeur maximale | max-w-md, max-w-6xl |
h-full, min-h-screen |
hauteur du parent, au moins la hauteur de la fenêtre | min-h-screen |
size-{n} |
largeur et hauteur ensemble | size-8 |
Typographie
Le titre du catalogue, puis une ligne plus petite et grise qui compte les jeux :
<h1 class="text-3xl font-semibold tracking-tight">Catalogue</h1>
<p class="mt-2 text-zinc-600">{{ $games->count() }} jeux à emprunter pour trois semaines.</p>
| Classe | Ce qu'elle fait | Exemple |
|---|---|---|
text-{taille} |
taille du texte, avec une hauteur de ligne adaptée | text-xs, text-sm, text-base, text-lg, text-3xl |
font-{graisse} |
épaisseur des lettres | font-normal, font-medium, font-semibold |
leading-{valeur} |
hauteur de ligne | leading-tight, leading-relaxed |
tracking-{valeur} |
espace entre les lettres | tracking-tight, tracking-wide |
uppercase |
tout en majuscules | uppercase |
text-center |
texte centré | text-left, text-center |
La ludothèque télécharge sa police en trois graisses : 400, 500 et 600. Ses titres utilisent donc font-semibold, la graisse 600. Avec font-bold, la graisse 700, le navigateur ne trouve aucun fichier : il se rabat sur la graisse 600 ou imite un gras plus épais.
Couleurs
Un message d'alerte en haut de la page des prêts :
<p class="rounded-md bg-red-50 px-4 py-3 text-sm text-red-800">
Deux prêts sont en retard.
</p>
Une classe de couleur réunit trois parties : ce qui est coloré, une teinte et une nuance. bg-red-50 colore le fond, text-red-800 le texte. Tailwind propose une vingtaine de teintes, dont zinc, gray, red, amber, green, teal, sky et indigo. Chacune existe en onze nuances, de 50, presque blanc, à 950, presque noir. S'y ajoutent black et white. La ludothèque utilise zinc, un gris neutre, pour le texte, les fonds et les bordures.
| Classe | Ce qu'elle colore | Exemple |
|---|---|---|
bg-{couleur} |
le fond | bg-white, bg-zinc-50 |
text-{couleur} |
le texte | text-zinc-600 |
border-{couleur} |
la bordure | border-zinc-200 |
{classe}/{opacité} |
la même couleur, transparente | bg-black/50, à moitié transparent |
bg-linear-to-{direction}, from-, to- |
un dégradé | bg-linear-to-r from-zinc-50 to-zinc-200 |
Pour un texte, gardez un contraste suffisant avec son fond : au moins 4,5 pour 1 pour un texte courant. Sur un fond blanc, text-zinc-500 atteint 4,8 pour 1, et text-zinc-600 7,7 pour 1. text-zinc-400 n'atteint que 2,6 pour 1 : il paraît lisible sur un écran neuf, mais plus sur un vieil écran ni en plein soleil.
Une couleur de marque avec @theme
La ludothèque a sa couleur, un bleu canard, qui ne fait pas partie des teintes de Tailwind. Elle la déclare dans le bloc @theme de resources/css/app.css, sous --font-sans, en onze nuances :
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
/* La couleur de la ludothèque : un bleu canard, de 50 (très clair) à 950 (très foncé). */
--color-brand-50: oklch(0.98 0.016 195);
--color-brand-100: oklch(0.95 0.04 195);
--color-brand-200: oklch(0.9 0.07 195);
--color-brand-300: oklch(0.83 0.1 195);
--color-brand-400: oklch(0.74 0.12 195);
--color-brand-500: oklch(0.64 0.12 196);
--color-brand-600: oklch(0.54 0.105 198);
--color-brand-700: oklch(0.46 0.09 200);
--color-brand-800: oklch(0.4 0.075 201);
--color-brand-900: oklch(0.35 0.06 202);
--color-brand-950: oklch(0.25 0.045 204);
}
Une variable dont le nom commence par --color- crée une couleur, avec toutes ses classes : bg-brand-600, text-brand-700, border-brand-600, et les autres. Une valeur oklch(…) donne trois nombres : la luminosité, de 0 à 1, l'intensité de la couleur, puis sa teinte sur un cercle de 360 degrés. Les nuances gardent ici la même teinte, autour de 195, et deviennent de plus en plus sombres.
.bg-brand-600{background-color:var(--color-brand-600)}
Les vues écrivent brand, le rôle de la couleur, et non teal ou sky. Si la ludothèque change de couleur, vous modifiez ces onze lignes, et tout le site suit. Le texte blanc sur brand-600 atteint 4,7 pour 1, au-dessus du minimum de 4,5 pour 1.
Bordures et ombres
Un champ de recherche du catalogue :
<input type="search" name="q" placeholder="Chercher un jeu"
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm shadow-xs">
| Classe | Ce qu'elle fait | Exemple |
|---|---|---|
border, border-b |
bordure de 1 pixel, sur les quatre côtés ou en bas | border border-zinc-200 |
rounded-{taille} |
coins arrondis | rounded-md, rounded-lg, rounded-xl, rounded-full |
shadow-{taille} |
ombre portée | shadow-xs, shadow-sm, shadow-md |
ring-{n} |
contour dessiné par une ombre, qui ne décale pas les voisins | ring-1 ring-zinc-300 |
divide-y |
trait horizontal entre les enfants | divide-y divide-zinc-200 |
Écrivez toujours la couleur d'une bordure, comme border border-zinc-200. Les exemples écrits pour Tailwind 3 l'omettent souvent. Dans ces exemples, border, shadow-sm et ring n'ont pas le même effet qu'en Tailwind 4 : la section 7 compare les deux versions.
Flex
Une ligne de prêt aligne le jeu et le membre à gauche, et le statut à droite :
<div class="flex items-center justify-between gap-4 border-b border-zinc-200 py-3">
<div>
<p class="font-medium">Marée d'étoiles</p>
<p class="text-sm text-zinc-600">Emprunté par Amina Benali, à rendre le 12 octobre</p>
</div>
<span class="shrink-0 text-sm font-medium text-red-700">En retard</span>
</div>
flex place les deux enfants du <div> côte à côte. justify-between pousse le premier à gauche et le dernier à droite. items-center les centre en hauteur. shrink-0 empêche le statut de rétrécir quand la phrase de gauche est longue : c'est elle qui passe à la ligne.
| Classe | Ce qu'elle fait | Exemple |
|---|---|---|
flex, flex-col |
aligne les enfants en ligne, ou en colonne | flex flex-col |
flex-wrap |
passe à la ligne quand la place manque | flex flex-wrap |
items-{position} |
position des enfants en hauteur, quand ils sont en ligne | items-center, items-start |
justify-{position} |
répartition des enfants dans le sens de la ligne | justify-between, justify-end |
gap-{n} |
espace entre les enfants | gap-4 |
flex-1 |
l'enfant prend la place restante | flex-1 |
Grid
Le catalogue range les cartes des jeux sur trois colonnes de même largeur :
<ul class="grid grid-cols-3 gap-6">
<li><article class="rounded-lg border border-zinc-200 bg-white p-6 shadow-xs">…</article></li>
<li><article class="rounded-lg border border-zinc-200 bg-white p-6 shadow-xs">…</article></li>
<li><article class="rounded-lg border border-zinc-200 bg-white p-6 shadow-xs">…</article></li>
</ul>
Une grille découpe la largeur en colonnes, et range les enfants de gauche à droite, puis ligne par ligne. Avec Flex, la largeur d'un enfant dépend de son contenu. Avec Grid, elle dépend de la colonne : les cartes sont alignées même si leurs titres n'ont pas la même longueur. grid-cols-{n} fixe le nombre de colonnes, et gap-{n} écarte les lignes et les colonnes.
4. Écrans et états
D'abord le téléphone
Trois colonnes sur un téléphone de 375 pixels de large donnent des cartes d'environ 100 pixels, où « Les Comptoirs de Safran » ne tient pas. Une classe précédée d'un préfixe de largeur, comme sm:, ne s'applique qu'à partir de cette largeur. Voici la grille du catalogue de la ludothèque :
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
grid seul range les cartes sur une colonne, à toutes les largeurs. À partir de 640 pixels, sm:grid-cols-2 passe à deux colonnes. À partir de 1 024 pixels, lg:grid-cols-3 passe à trois, et xl:grid-cols-4 à quatre dès 1 280 pixels. Tailwind écrit ces classes dans une requête média, un bloc de règles que le navigateur n'applique que si la fenêtre remplit une condition. Ici, la fenêtre doit mesurer au moins 40 rem de large :
@media (width>=40rem){… .sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))} …}
Dans le nom de la règle, \: protège le deux-points du préfixe, qui a un autre sens en CSS.
Vous écrivez donc d'abord la page du téléphone, sans préfixe, puis vous ajoutez ce qui change sur un écran plus large. Cette façon de faire s'appelle mobile first. sm: ne veut pas dire « sur un petit écran », mais « à partir de 640 pixels ».
| Préfixe | À partir de | Écran typique |
|---|---|---|
| aucun | 0 pixel | téléphone |
sm: |
640 pixels (40 rem) | grand téléphone couché |
md: |
768 pixels (48 rem) | tablette |
lg: |
1 024 pixels (64 rem) | ordinateur portable |
xl: |
1 280 pixels (80 rem) | écran de bureau |
2xl: |
1 536 pixels (96 rem) | grand écran |

Pour tester, ouvrez les outils de développement du navigateur avec F12, puis le mode d'affichage mobile, Ctrl+Maj+M dans Chrome et Firefox. Vous choisissez alors la largeur de la page.
Les états d'un bouton
Un bouton change d'aspect quand la souris le survole, quand il reçoit le focus au clavier, et quand il est désactivé. Un préfixe d'état ne s'applique que dans cet état :
<button type="submit"
class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 disabled:cursor-not-allowed disabled:opacity-50">
Ajouter le jeu
</button>
Tailwind place lui aussi hover: dans une requête média. La règle ne vaut que sur un appareil qui a une souris, et non sur un écran tactile :
@media (hover:hover){.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)} …}
Le focus est l'état de l'élément qui reçoit les touches du clavier. La touche Tab le fait passer d'un lien ou d'un bouton au suivant. Sur un bouton, focus-visible: ne s'applique qu'au focus reçu par le clavier, pas après un clic de souris.
| Préfixe | S'applique | Exemple |
|---|---|---|
hover: |
au survol de la souris | hover:bg-brand-700 |
focus-visible: |
au focus au clavier | focus-visible:outline-2 |
active: |
pendant le clic | active:bg-brand-800 |
disabled: |
quand l'élément a l'attribut disabled |
disabled:opacity-50 |
Attention
Ne retirez jamais le contour de focus sans le remplacer. Beaucoup d'exemples écrivent outline-none sur les boutons et les champs, parce que le contour du navigateur ne leur plaît pas. Une personne qui navigue au clavier ne sait alors plus où elle se trouve dans la page. Si vous changez ce contour, écrivez-en un autre avec focus-visible:, puis parcourez la page avec la touche Tab.
Combiner les préfixes
Les préfixes s'additionnent. Écrivez d'abord la largeur, puis l'état :
<a href="{{ route('games.index') }}" class="block py-2 md:py-0 md:hover:underline">Catalogue</a>
md:hover:underline ne souligne le lien qu'au survol, et seulement à partir de 768 pixels.
Le mode sombre
Le préfixe dark: applique une classe quand le système de la personne est réglé en mode sombre. Voici le <body> du layout de la ludothèque :
<body class="flex min-h-screen flex-col bg-zinc-50 font-sans text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100">
En mode clair, la page a un fond zinc-50 et un texte zinc-900. En mode sombre, les deux s'inversent : fond zinc-950, texte zinc-100. Chaque classe de couleur demande ainsi sa version sombre, dans chaque composant et chaque vue. Un mode sombre à moitié fait affiche du texte gris foncé sur fond noir : ajoutez les classes dark: partout, ou nulle part.
5. Des classes aux composants Blade
Le bouton de la section 4 compte treize classes. La ludothèque en affiche partout : ajouter un jeu, annuler, supprimer. Recopier ces treize classes dans chaque vue reviendrait à la situation du chapitre 9, avant le layout : un changement de couleur demanderait d'ouvrir toutes les vues. Le composant Blade règle ce problème ici aussi.
La carte et son image
Voici le composant card de la ludothèque, components/card.blade.php :
<article {{ $attributes->class([
'overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs',
'dark:border-zinc-800 dark:bg-zinc-900',
]) }}>
@isset($image)
{{ $image }}
@endisset
<div class="p-5">
{{ $slot }}
</div>
</article>
$attributes->class([...])écrit les classes du tableau, puis ajoute celles de la balise<x-card>, commemerge()au chapitre 9.@isset($image)affiche le slot nomméimageseulement si la page en donne un, comme$noteau chapitre 9. L'image touche les bords de la carte, etoverflow-hiddencoupe ses coins pour suivre l'arrondirounded-xl.<div class="p-5">écarte le texte des bords, mais pas l'image.
Le catalogue n'écrit plus que le contenu de chaque carte :
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@foreach ($games as $game)
<li>
<x-card class="h-full transition-shadow hover:shadow-md">
<x-slot:image>
<img src="{{ asset($game->coverPath()) }}" alt="" width="400" height="300" class="aspect-4/3 w-full object-cover">
</x-slot:image>
<p class="text-xs font-semibold tracking-wide text-brand-700 uppercase dark:text-brand-300">{{ $game->category }}</p>
<h2 class="mt-1 text-lg font-semibold">
<a href="{{ route('games.show', $game->id) }}" class="rounded-sm hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600">{{ $game->title }}</a>
</h2>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">{{ $game->playersLabel() }} · {{ $game->duration }} min</p>
</x-card>
</li>
@endforeach
</ul>
class="h-full …" s'ajoute aux classes du composant : toutes les cartes d'une ligne de la grille prennent la même hauteur, et leur ombre grandit au survol. aspect-4/3 donne à l'image un rapport largeur sur hauteur de 4 pour 3, et object-cover la recadre sans la déformer.
Des classes selon une condition : @class
Le statut d'un prêt s'affiche dans une pastille dont la couleur dépend du statut : bleu pour un prêt en cours, rouge pour un retard, gris pour un jeu rendu. La directive @class reçoit un tableau. Une valeur sans clé est toujours écrite. Une clé est écrite seulement si sa valeur vaut true. Voici le composant badge :
@props(['status'])
<span @class([
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ring-1 ring-inset',
'bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/30' => $status === 'ongoing',
'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-400/10 dark:text-red-300 dark:ring-red-400/30' => $status === 'overdue',
'bg-zinc-100 text-zinc-600 ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-300 dark:ring-zinc-400/30' => $status === 'returned',
])>
{{ $slot }}
</span>
<x-badge status="overdue">En retard</x-badge>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ring-1 ring-inset bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-400/10 dark:text-red-300 dark:ring-red-400/30">
En retard
</span>
@class a gardé la première chaîne et la seule clé dont la condition est vraie. ring-1 ring-inset ring-red-600/20 trace un contour de 1 pixel à l'intérieur de la pastille, dans un rouge opaque à 20 %.
Le statut ne vient pas d'une colonne de la base. Le modèle Loan le calcule à partir des dates du prêt :
public function status(): string
{
if ($this->returned_at !== null) {
return 'returned';
}
if ($this->due_at->lt(today())) {
return 'overdue';
}
return 'ongoing';
}
lt() signifie « plus petit que » : la date de retour prévue est passée. Une seconde méthode, statusLabel(), traduit le statut avec l'expression match du chapitre 7 de la POO. La page des prêts écrit donc :
<x-badge :status="$loan->status()">{{ $loan->statusLabel() }}</x-badge>
Le piège des noms construits
Pour les catégories des jeux, une étiquette colorée semble plus simple à écrire avec une prop color placée dans le nom de la classe. Voici un composant tag écrit de cette façon :
@props(['color'])
<span class="rounded-md bg-{{ $color }}-100 px-2 py-1 text-xs text-{{ $color }}-800">
{{ $slot }}
</span>
<x-tag color="lime">Coopératif</x-tag>
<span class="rounded-md bg-lime-100 px-2 py-1 text-xs text-lime-800">
Coopératif
</span>
Le HTML contient bien bg-lime-100, mais l'étiquette s'affiche sans fond vert. Cherchez la classe dans le CSS produit :
npm run build
grep -c "bg-lime-100" public/build/assets/app-*.css
0
Sous PowerShell, Select-String -Path public/build/assets/app-*.css -Pattern "bg-lime-100" remplace grep, et n'affiche alors rien.
Aucune règle. Tailwind lit tag.blade.php comme du texte, sans exécuter Blade : il y trouve bg-{{ $color }}-100, et non bg-lime-100. Le nom complet n'existe qu'au moment où Laravel affiche la page, bien après le build. Écrivez toujours les noms de classes en entier dans vos fichiers. Faites correspondre chaque valeur de la prop à des classes complètes, avec @class, comme dans badge :
@props(['color'])
<span @class([
'rounded-md px-2 py-1 text-xs',
'bg-lime-100 text-lime-800' => $color === 'lime',
'bg-sky-100 text-sky-800' => $color === 'sky',
])>
{{ $slot }}
</span>
npm run build
grep -c "bg-lime-100" public/build/assets/app-*.css
1
La règle vaut aussi pour PHP et JavaScript. 'bg-' . $color . '-100' dans un contrôleur, ou une classe assemblée dans app.js, ne produisent pas de règle CSS.
Un bouton à variantes
Le bouton existe en trois variantes, trois aspects choisis par une prop : principal, secondaire et danger. Voici le composant button :
@props(['variant' => 'primary'])
<button {{ $attributes->merge(['type' => 'button'])->class([
'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600 disabled:hover:bg-brand-600' => $variant === 'primary',
'bg-white text-zinc-900 ring-1 ring-zinc-300 ring-inset hover:bg-zinc-50 focus-visible:outline-zinc-500 disabled:hover:bg-white dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700' => $variant === 'secondary',
'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600 disabled:hover:bg-red-600' => $variant === 'danger',
]) }}>
{{ $slot }}
</button>
->merge(['type' => 'button'])donne au bouton le typebuttonpar défaut. Sans attributtype, un<button>placé dans un formulaire l'envoie. Pour tout attribut autre queclass, la valeur écrite dans la balise remplace celle demerge().->class([...])reçoit le même tableau que@class, et y ajoute les classes écrites dans la balise<x-button>.disabled:hover:bg-brand-600garde la même couleur au survol d'un bouton désactivé : rien ne laisse croire qu'il réagit.
<x-button>Emprunter</x-button>
<x-button variant="danger" type="submit">Supprimer le jeu</x-button>
<x-button variant="secondary" class="w-full" disabled>Indisponible</x-button>
<button class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600 disabled:hover:bg-brand-600" type="button">
Emprunter
</button>
<button class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600 disabled:hover:bg-red-600" type="submit">
Supprimer le jeu
</button>
<button class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-white text-zinc-900 ring-1 ring-zinc-300 ring-inset hover:bg-zinc-50 focus-visible:outline-zinc-500 disabled:hover:bg-white dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700 w-full" type="button" disabled="disabled">
Indisponible
</button>
Le second bouton garde type="submit", écrit dans la balise. Le troisième reçoit w-full à la fin de ses classes, et l'attribut disabled arrive dans la balise sous la forme disabled="disabled". Chaque bouton principal du site utilise brand-600 : si la ludothèque change de couleur, aucune vue ne change.
Une classe ajoutée dans la balise ne doit pas contredire une classe du composant. Essayez <x-button class="bg-amber-600">Ambre</x-button> puis <x-button class="bg-orange-600">Orange</x-button>. Les deux boutons reçoivent bg-brand-600 et la seconde couleur. Le bouton « Ambre » reste bleu canard, et le bouton « Orange » devient orange.
Quand deux classes règlent la même propriété, le navigateur applique la règle placée le plus bas dans le fichier CSS. Tailwind range lui-même ses règles, et .bg-amber-600 y précède .bg-brand-600, qui précède .bg-orange-600. L'ordre des classes dans l'attribut class ne compte pas. Pour un autre aspect, ajoutez une variante au composant, plutôt qu'une couleur dans la balise.

6. Le menu et son JavaScript
Sur un téléphone, les trois liens du menu prennent trop de place. La ludothèque les cache derrière un bouton à trois traits, le bouton « burger », et les affiche en ligne à partir de 768 pixels. Voici l'en-tête du layout :
<header class="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-3 sm:px-6 lg:px-8">
<a href="{{ route('games.index') }}" class="flex items-center gap-2 rounded-md text-lg font-semibold tracking-tight focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-600">
<span class="grid size-8 place-items-center rounded-lg bg-brand-600 text-sm font-semibold text-white" aria-hidden="true">LT</span>
Ludothèque des Tilleuls
</a>
<button
type="button"
id="menu-button"
class="group rounded-md p-2 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-brand-600 md:hidden dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white"
aria-controls="main-menu"
aria-expanded="false"
>
<span class="sr-only">Menu</span>
<svg class="size-6 group-aria-expanded:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg class="hidden size-6 group-aria-expanded:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<nav id="main-menu" class="hidden w-full md:block md:w-auto" aria-label="Navigation principale">
<ul class="flex flex-col gap-1 border-t border-zinc-200 py-2 md:flex-row md:items-center md:border-0 md:py-0 dark:border-zinc-800">
<li><x-nav-link :href="route('games.index')" :active="request()->routeIs('games.index', 'games.show')">Catalogue</x-nav-link></li>
<li><x-nav-link :href="route('loans.index')" :active="request()->routeIs('loans.*')">Prêts</x-nav-link></li>
<li><x-nav-link :href="route('games.create')" :active="request()->routeIs('games.create')">Ajouter un jeu</x-nav-link></li>
</ul>
</nav>
</div>
</header>
- Sur un téléphone,
hiddencache le<nav>, et la liste est en colonne avecflex-col. À partir de 768 pixels,md:blockaffiche le menu,md:flex-rowmet les liens en ligne, etmd:hiddencache le bouton. sr-onlycache le mot « Menu » à l'écran, mais les lecteurs d'écran, les programmes qui lisent la page à voix haute aux personnes aveugles ou malvoyantes, le lisent. Le bouton n'affiche qu'un dessin, et le lecteur annonce « Menu ».groupmarque le bouton.group-aria-expanded:hiddencache le premier dessin, les trois traits, quand le bouton portearia-expanded="true".group-aria-expanded:blockaffiche alors le second, une croix.
Le lien actif : <x-nav-link>
Chaque lien du menu est un composant qui reçoit son adresse et un booléen active :
@props(['href', 'active' => false])
<a
href="{{ $href }}"
@if ($active) aria-current="page" @endif
@class([
'block rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-brand-600',
'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200' => $active,
'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white' => ! $active,
])
>
{{ $slot }}
</a>
request()->routeIs('loans.*') vaut true quand la page affichée vient d'une route dont le nom commence par loans.. Sur la page des prêts, le lien « Prêts » reçoit donc le fond brand-50 et l'attribut aria-current="page", qui annonce au lecteur d'écran la page en cours :
<x-nav-link href="/prets" :active="true">Prêts</x-nav-link>
<a
href="/prets"
aria-current="page" class="block rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200"
>
Prêts
</a>
Le script du bouton
Il reste à ouvrir le menu quand vous cliquez sur le bouton. Ce code s'écrit dans resources/js/app.js, la seconde source de @vite. Dans un projet neuf, ce fichier ne contient qu'un commentaire vide, //. Voici celui de la ludothèque :
// Le menu mobile : le bouton burger affiche ou cache la liste des liens.
const menuButton = document.querySelector('#menu-button');
const menu = document.querySelector('#main-menu');
if (menuButton && menu) {
menuButton.addEventListener('click', () => {
const isOpen = menuButton.getAttribute('aria-expanded') === 'true';
menuButton.setAttribute('aria-expanded', String(!isOpen));
menu.classList.toggle('hidden');
});
}
document.querySelector('#menu-button')renvoie l'élément dont l'idvautmenu-button, ounulls'il n'existe pas. Leifévite une erreur sur une page sans menu.addEventListener('click', …)exécute la fonction à chaque clic sur le bouton.getAttribute('aria-expanded') === 'true'lit l'état actuel du menu, etsetAttributeécrit l'état inverse.classList.toggle('hidden')retire la classehiddendu<nav>si elle y est, et l'ajoute sinon.
Sur une page de 375 pixels, cliquez sur le bouton, puis ouvrez l'onglet Éléments des outils de développement :
<button type="button" id="menu-button" class="group …" aria-controls="main-menu" aria-expanded="true">
…
<nav id="main-menu" class="w-full md:block md:w-auto" aria-label="Navigation principale">
La classe hidden a disparu du <nav>, et les trois liens s'affichent l'un sous l'autre. Le bouton porte aria-expanded="true" et montre la croix. Un second clic referme le menu.
Un seul attribut, aria-expanded, sert à la fois au lecteur d'écran, qui annonce si le menu est ouvert, et au choix du dessin par group-aria-expanded:. aria-controls indique au lecteur quel élément le bouton commande.
@vite charge app.js avec type="module". Le navigateur exécute donc le script après avoir lu toute la page, et querySelector trouve le bouton et le menu. Tailwind lit aussi app.js : une classe que seul votre JavaScript ajoute doit y être écrite en entier, comme 'hidden' ici.
7. Trouver une classe
La documentation officielle, tailwindcss.com/docs, consacre une page à chaque famille de classes. Sa recherche s'ouvre avec Ctrl+K. Tapez le nom de la propriété CSS que vous connaissez, en anglais : « letter spacing » mène à la page des classes tracking-*. Chaque page commence par un tableau qui donne, pour chaque classe, la règle CSS qu'elle produit. Voici celle de tracking-tight, le resserrement du titre du catalogue, dans le fichier produit :
.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}
La variable --tracking-tight vaut -.025em : les lettres se rapprochent d'un quarantième de la taille du texte.
L'extension Tailwind CSS IntelliSense, dans la liste des extensions du chapitre 3, complète les noms de classes pendant que vous tapez dans un attribut class. Elle montre un carré de la couleur à côté de chaque classe de couleur. Au survol d'une classe, elle affiche la règle CSS correspondante.
Les tutoriels et les assistants de code IA proposent souvent du code écrit pour Tailwind 3. Certains anciens noms fonctionnent encore, d'autres ont changé d'effet ou n'existent plus :
| Tailwind 3 | Tailwind 4 | L'ancien nom en Tailwind 4 |
|---|---|---|
un fichier tailwind.config.js |
@theme dans app.css |
le fichier n'est plus lu |
@tailwind base; et deux lignes voisines |
@import 'tailwindcss'; |
ne charge plus Tailwind en entier |
bg-opacity-50 |
bg-black/50 |
aucun effet |
shadow-sm |
shadow-xs |
une ombre plus marquée |
rounded-sm |
rounded-xs |
un arrondi plus grand |
ring |
ring-3 |
un contour de 1 pixel |
border |
border border-zinc-200 |
une bordure de la couleur du texte |
bg-gradient-to-r |
bg-linear-to-r |
fonctionne encore |
flex-shrink-0 |
shrink-0 |
fonctionne encore |
Si une classe trouvée en ligne n'a pas l'effet attendu, cherchez-la d'abord dans ce tableau, puis dans la documentation. Voici, raccourcies, les règles de shadow-xs et de shadow-sm dans le fichier produit :
.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);…}
.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);…}
shadow-xs reprend l'ombre de la section 1 : 1 pixel vers le bas, 2 pixels de flou, du noir à 5 %. shadow-sm superpose deux ombres noires à 10 %.
À retenir
- Une classe utilitaire applique une seule propriété CSS, et Tailwind ne produit que les classes trouvées dans vos fichiers.
@vitecharge le CSS et le JavaScript, etnpm run devtourne pendant le travail.- Une classe sans préfixe vaut pour toutes les largeurs, et
md:oulg:l'adaptent aux écrans plus larges. - Un composant Blade écrit une seule fois une longue liste de classes, choisies avec
@classou$attributes->class(). - Un nom de classe s'écrit en entier, car Tailwind ne voit pas
bg-{{ $color }}-100.