Aller au contenu

Tailwind CSS

Bloc Laravel · séance 5 · ~60 min

Slides du chapitre (PDF)PDF

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 :

  1. 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,
  2. charger le CSS et le JavaScript de Vite dans un layout, et choisir entre npm run dev et npm run build,
  3. 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,
  4. regrouper ces classes dans des composants Blade à variantes, sans construire de nom de classe à partir d'une variable,
  5. 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>.

La carte « Les Comptoirs de Safran » dans le navigateur : un cadre blanc aux coins arrondis, bordé de gris clair, avec le titre en gras et la ligne « 2 à 4 joueurs · 90 min » en gris, sur fond gris très clair

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 @source ajoutent des dossiers à lire. Tailwind lit déjà tous les fichiers du projet, sauf ceux que Git ignore, comme vendor/ et storage/framework/views/. La première ligne ajoute les templates de pagination de Laravel, rangés dans vendor/. La seconde ajoute les vues compilées, que Blade écrit dans storage/framework/views/.
  • @theme déclare des variables de thème, des variables CSS qui créent des classes. Ici, --font-sans choisit la police Instrument Sans pour la classe font-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 que vite.config.js déclare, le CSS et le JavaScript, et écrit une balise pour chacune.
  • @fonts charge la police Instrument Sans. vite.config.js la télécharge, et @theme la 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

Le catalogue de la ludothèque à deux largeurs, côte à côte : à 1 280 pixels, quatre cartes par ligne et les trois liens du menu dans l'en-tête ; à 375 pixels, une seule carte sur toute la largeur et le bouton à trois traits à la place des liens

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>, comme merge() au chapitre 9.
  • @isset($image) affiche le slot nommé image seulement si la page en donne un, comme $note au chapitre 9. L'image touche les bords de la carte, et overflow-hidden coupe ses coins pour suivre l'arrondi rounded-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 type button par défaut. Sans attribut type, un <button> placé dans un formulaire l'envoie. Pour tout attribut autre que class, la valeur écrite dans la balise remplace celle de merge().
  • ->class([...]) reçoit le même tableau que @class, et y ajoute les classes écrites dans la balise <x-button>.
  • disabled:hover:bg-brand-600 garde 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.

Les trois boutons de l'exemple : « Emprunter » en bleu canard, « Supprimer le jeu » en rouge, puis « Indisponible », blanc et pâli, sur toute la largeur

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, hidden cache le <nav>, et la liste est en colonne avec flex-col. À partir de 768 pixels, md:block affiche le menu, md:flex-row met les liens en ligne, et md:hidden cache le bouton.
  • sr-only cache 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 ».
  • group marque le bouton. group-aria-expanded:hidden cache le premier dessin, les trois traits, quand le bouton porte aria-expanded="true". group-aria-expanded:block affiche alors le second, une croix.

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'id vaut menu-button, ou null s'il n'existe pas. Le if é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, et setAttribute écrit l'état inverse.
  • classList.toggle('hidden') retire la classe hidden du <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.
  • @vite charge le CSS et le JavaScript, et npm run dev tourne pendant le travail.
  • Une classe sans préfixe vaut pour toutes les largeurs, et md: ou lg: l'adaptent aux écrans plus larges.
  • Un composant Blade écrit une seule fois une longue liste de classes, choisies avec @class ou $attributes->class().
  • Un nom de classe s'écrit en entier, car Tailwind ne voit pas bg-{{ $color }}-100.

Six cartes

Qu'est-ce qu'une classe utilitaire ?

Une classe qui applique une seule propriété CSS, comme p-6 pour la marge intérieure.

Pourquoi une nouvelle classe reste-t-elle sans effet après npm run build ?

Le CSS de public/build/ ne contient que les classes présentes au moment du build. Lancez npm run dev.

Que veut dire sm:grid-cols-2 ?

Deux colonnes à partir de 640 pixels de large.

Pourquoi bg-{{ $color }}-100 ne colore-t-il rien ?

Tailwind lit le fichier comme du texte, et n'y trouve pas bg-lime-100.

@class ou $attributes->class() ?

@class sur une balise ordinaire. $attributes->class() dans un composant, qui garde les classes de <x-…>.

À quoi sert @theme ?

À déclarer des variables qui créent des classes : --color-brand-600 crée bg-brand-600.