shadcn-vue : copier, coller, posséder
Temps 1 · séance 3 · ~35 min
Les slides du chapitre (PDF)PDF
- Expliquer où vit un composant shadcn-vue et pourquoi il vous appartient.
- Lire un composant Vue comme un composant Blade : un fichier, des props, un slot.
- Ajouter un composant à votre projet avec la commande de shadcn-vue.
- Choisir la variante et la taille d'un bouton.
- Construire le tableau d'administration des projets avec
Tableet un bouton « Nouveau projet ».
1. Un composant dans votre dépôt
Ouvrez le dossier resources/js/components/ui/. Le kit y a rangé une vingtaine de dossiers, un par composant d'interface : button, input, label, card, dialog, badge, sonner, checkbox, sidebar et d'autres. Vous les utilisez depuis le chapitre 1.04 sans le savoir : la page de connexion est faite de Input, de Label et de Button.
Ces composants viennent de shadcn-vue. Ce n'est pas une bibliothèque installée dans node_modules, mais une collection de composants que l'on copie dans son propre projet. Ouvrez resources/js/components/ui/button/Button.vue :
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
Le fichier tient en trente lignes, et il est dans votre dépôt Git. Le travail difficile est ailleurs : Primitive vient de reka-ui, un paquet installé dans node_modules. reka-ui gère ce qui ne se voit pas, comme le clavier, le focus et les attributs d'accessibilité. shadcn-vue y ajoute l'apparence, en classes Tailwind, dans un fichier que vous possédez.

- Vous lisez tout le code. Un comportement surprenant se comprend en ouvrant le fichier, sans fouiller
node_modules. - Vous modifiez sans attendre personne. Pour arrondir davantage tous vos boutons, vous changez une classe dans un fichier à vous, et votre site suit. Lequel des deux fichiers du dossier
button/? La réponse est juste après. - Une mise à jour ne casse rien. Aucun
npm updatene touche à vos composants, puisqu'ils ne viennent pas d'un paquet.
Un composant shadcn-vue est du code à vous, rangé dans resources/js/components/ui/.
Les composants du kit sont écrits en TypeScript, comme le reste du kit. Vous les importez sans rien changer depuis vos pages en JavaScript, comme les fonctions de Wayfinder.
Deux fichiers, deux rôles : Button.vue et index.ts
Le dossier button/ contient deux fichiers, et chacun a son rôle.
Button.vueporte la structure : ce que le composant reçoit et ce qu'il produit. Il déclare les propsvariant,sizeetclass, pose un<slot />pour le texte du bouton, et produit unPrimitive. Relisez-le : il n'écrit aucune classe Tailwind lui-même. Sa seule ligne:classappellebuttonVariants().index.tsporte l'apparence : toutes les classes.buttonVariantsy est construit parcva()à partir de trois listes : d'abord les classes que tout bouton reçoit (inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium …), puis une liste par variante, puis une liste par taille. Vous lirez les variantes à la section 4.
Pour changer la forme de tous les boutons, vous ouvrez donc index.ts, pas Button.vue. rounded-md y apparaît trois fois : dans la première liste, puis dans les tailles sm et lg. Remplacez les trois par rounded-full, enregistrez : chaque bouton du site devient une pilule, « Nouveau projet » comme « Se connecter », sans toucher à Button.vue ni à aucune page. Vous ouvrez Button.vue le jour où c'est la structure qui change, une prop de plus par exemple ; vous n'y toucherez pas dans ce cours. Ce partage est celui des composants à variantes, Button, Badge, Alert. Un composant sans variante, comme Input, écrit ses classes dans son .vue, et son index.ts ne fait que l'exporter : ouvrez les deux fichiers, le bon se reconnaît à ses classes.

Un composant Vue, comme un composant Blade
En 5XCOS, vous avez écrit alert.blade.php dans resources/views/components/, puis <x-alert type="danger">Le concert de 22 h est complet.</x-alert> dans une vue. Un composant Vue repose sur les trois mêmes idées : un fichier, des props, un slot. Voici le même composant en Vue, dans resources/js/components/Alert.vue :
<script setup>
defineProps({
type: { type: String, default: 'info' },
});
</script>
<template>
<div :class="type === 'danger' ? 'text-red-700' : 'text-neutral-700'">
<slot />
</div>
</template>
Et voici une page qui s'en sert deux fois :
<script setup>
import Alert from '@/components/Alert.vue';
</script>
<template>
<Alert>Les portes ouvrent à 17 h 30.</Alert>
<Alert type="danger">Le concert de 22 h est complet.</Alert>
</template>
Le navigateur reçoit deux <div>, une par balise <Alert> :
<div class="text-neutral-700">Les portes ouvrent à 17 h 30.</div>
<div class="text-red-700">Le concert de 22 h est complet.</div>
Le fichier. Un composant Vue est un fichier .vue qui contient un <script setup> et un <template>, comme les pages que vous écrivez depuis le chapitre 1.03. Son nom commence par une majuscule, en un ou plusieurs mots collés : Alert.vue, ProjectCard.vue, RichTextEditor.vue. Vos composants vont dans resources/js/components/, ceux de shadcn-vue dans son sous-dossier ui/. Blade trouvait <x-alert> tout seul d'après le nom du fichier ; Vue demande une ligne import dans chaque page qui l'utilise, et la balise porte le nom importé : <Alert>.
Les props. Une prop est une valeur que la page donne au composant par un attribut de la balise, comme type="danger". Le composant la déclare avec defineProps, qui joue le rôle de @props : le nom, le type attendu et la valeur par défaut. Dans le template, la prop s'utilise par son nom, sans $ : c'est type qui choisit la classe de la <div>. La première <Alert> de la page n'a pas d'attribut : type vaut 'info', la valeur par défaut, et la <div> reçoit text-neutral-700. Les deux-points gardent le sens de Blade : :type="project.type" passe la valeur d'une expression JavaScript, type="danger" passe le texte tel quel.
Le slot. <slot /> marque l'endroit où arrive ce que la page écrit entre les balises du composant, exactement comme {{ $slot }}. Le texte « Le concert de 22 h est complet. » n'est pas une prop : il entre par le slot, et il ressort à l'intérieur de la <div>. Le slot sert à tout ce qui n'est pas une simple valeur : une phrase, une icône, ou d'autres composants, comme le <Link> que Button recevra à la section 5.
Relisez maintenant Button.vue ci-dessus avec ces trois idées : defineProps déclare variant, size et class, et <slot /> reçoit le texte du bouton. <Button variant="destructive">Supprimer</Button> se lit donc comme <x-button type="destructive">Supprimer</x-button> se lisait en Blade : variant est une prop, « Supprimer » est le slot.
| Blade, en 5XCOS | Vue | |
|---|---|---|
| Le fichier | resources/views/components/alert.blade.php |
resources/js/components/Alert.vue |
| Le nom dans la page | <x-alert>, trouvé d'après le nom du fichier |
<Alert>, après import Alert from '@/components/Alert.vue'; |
| Les props | @props(['type' => 'info']), puis $type |
defineProps({ type: { type: String, default: 'info' } }), puis type |
| Une valeur calculée | :type="$concert->type" |
:type="project.type" |
| Le slot | {{ $slot }} |
<slot /> |
Vous n'écrivez aucun composant dans ce chapitre : vous utilisez ceux du kit. Vous écrirez les vôtres aux chapitres 1.15 et 1.18.
2. Ajouter un composant
Le tableau d'administration demande Table, et le formulaire du chapitre 1.12 demande Textarea, une zone de texte sur plusieurs lignes. Le kit n'a ni l'un ni l'autre. Ajoutez-les avec la commande de shadcn-vue, depuis le dossier du projet :
npx shadcn-vue@latest add table textarea
npx télécharge l'outil shadcn-vue et le lance sans l'installer dans le projet. @latest demande la dernière version. La première fois, npx peut vous demander la permission de télécharger le paquet : répondez y. Voici l'essentiel de la sortie :
✔ Checking registry.
- Installing dependencies.
added 1 package, and audited 228 packages in 1s
✔ Installing dependencies.
✔ Created 12 files:
- resources/js/components/ui/table/Table.vue
- resources/js/components/ui/table/TableBody.vue
- resources/js/components/ui/table/TableCaption.vue
- resources/js/components/ui/table/TableCell.vue
- resources/js/components/ui/table/TableEmpty.vue
- resources/js/components/ui/table/TableFooter.vue
- resources/js/components/ui/table/TableHead.vue
- resources/js/components/ui/table/TableHeader.vue
- resources/js/components/ui/table/TableRow.vue
- resources/js/components/ui/table/index.ts
- resources/js/components/ui/textarea/Textarea.vue
- resources/js/components/ui/textarea/index.ts
La commande ne pose aucune question sur le style ou les couleurs. Elle lit les réglages du kit dans le fichier components.json, à la racine du projet.
Le résultat se lit sur la dernière ligne : douze fichiers créés, tous dans resources/js/components/ui/. index.ts regroupe les composants d'un dossier, pour que vous les importiez en une ligne.

Lire package.json après la commande
« Installing dependencies » signale que la commande a aussi touché à package.json. Ouvrez-le, ou lancez git diff package.json. Deux lignes ont changé dans dependencies :
"@lucide/vue": "^1.61.0",
"cn": "^0.5.0",
Les numéros de version peuvent être plus récents chez vous. La première ligne relève la version des icônes Lucide. La seconde ajoute un petit paquet, cn, que les nouveaux composants importent avec import { cn } from "cn". Les composants du kit, eux, importent leur fonction cn de @/lib/utils. Les deux fusionnent des classes Tailwind, et elles cohabitent sans problème.
Prenez l'habitude de lire ce fichier après chaque commande qui installe quelque chose. Une dépendance ajoutée sans que vous le sachiez part en production avec votre site.
3. Les composants de ce temps
Le portfolio utilise onze composants d'ici la fin du temps 1. Neuf étaient déjà dans le kit.
| Composant | Import | Ce qu'il affiche | Vous le verrez |
|---|---|---|---|
Button |
@/components/ui/button |
un bouton, ou un lien qui en a l'apparence | dans ce chapitre |
Input |
@/components/ui/input |
un champ d'une ligne | au chapitre 1.12 |
Textarea |
@/components/ui/textarea |
une zone de texte de plusieurs lignes, ajoutée ci-dessus | au chapitre 1.12 |
Label |
@/components/ui/label |
l'étiquette d'un champ, reliée par for |
au chapitre 1.12 |
Checkbox |
@/components/ui/checkbox |
une case à cocher | au chapitre 1.12 |
Spinner |
@/components/ui/spinner |
une petite roue qui tourne pendant un envoi | au chapitre 1.12 |
Card |
@/components/ui/card |
un cadre avec un en-tête et un contenu, utilisé par les pages de connexion | si vous voulez encadrer un formulaire |
Table |
@/components/ui/table |
un tableau, ajouté ci-dessus | dans ce chapitre |
Dialog |
@/components/ui/dialog |
une boîte de dialogue au-dessus de la page | au chapitre 1.13 |
Badge |
@/components/ui/badge |
une petite étiquette arrondie, comme « Brouillon » | au chapitre 1.16 |
Toaster |
@/components/ui/sonner |
les notifications, appelées toasts, qui apparaissent dans un coin de l'écran | au chapitre 1.12 |
Un composant se découpe souvent en plusieurs morceaux. Le dossier table/ en exporte neuf : Table, TableHeader, TableBody, TableRow, TableHead, TableCell et trois autres. Chacun produit la balise HTML correspondante, avec ses classes : TableHead produit un <th>, TableCell un <td>.
Le dernier composant est déjà monté pour vous. Le layout du back-office, resources/js/layouts/app/AppSidebarLayout.vue, place un <Toaster /> à la fin de son template :
<template>
<AppShell variant="sidebar">
<AppSidebar />
<AppContent variant="sidebar" class="min-w-0 overflow-x-clip">
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot />
</AppContent>
<Toaster />
</AppShell>
</template>
Chaque page du back-office peut donc afficher une notification. Le chapitre 1.12 en fera apparaître une après l'enregistrement d'un projet.
4. Les variantes
Un bouton « Supprimer » ne ressemble pas à un bouton « Annuler ». Plutôt que d'écrire les classes à chaque fois, le composant Button propose des variantes, des apparences prêtes à l'emploi que vous choisissez par une prop. Elles sont définies dans resources/js/components/ui/button/index.ts, avec la fonction cva() du paquet class-variance-authority, qui associe chaque nom de variante à une liste de classes :
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
| Variante | Apparence | Usage dans le back-office |
|---|---|---|
default |
fond plein, de la couleur principale | l'action principale de la page : « Nouveau projet », « Créer le projet » |
destructive |
fond rouge | une action qui supprime : « Supprimer » |
outline |
bordure, fond de la page | une action secondaire dans une ligne : « Modifier » |
secondary |
fond gris clair | une action de retrait : « Annuler » |
ghost |
aucun fond tant que la souris ne passe pas | une icône seule |
link |
texte souligné au survol | un lien au milieu d'un texte |

La prop size règle la hauteur et les marges : default, sm, lg, et trois tailles carrées pour une icône seule, icon, icon-sm et icon-lg. Les deux se combinent :
<Button variant="destructive" size="sm">Supprimer</Button>
Sans prop, le bouton prend default pour les deux. Le composant Badge fonctionne de la même façon, avec les variantes default, secondary, destructive et outline.
5. Un bouton qui est un lien : as-child
Le bouton « Nouveau projet » mène à une autre page. Ce n'est pas une action, c'est un lien, et il doit rester une balise <a> pour que le navigateur le traite comme tel. Mais il doit avoir l'apparence d'un bouton. La prop as-child règle ce cas :
<Button as-child>
<Link :href="create()">Nouveau projet</Link>
</Button>
Avec as-child, le composant Primitive de reka-ui ne produit pas de <button>. Il donne ses classes à son enfant, ici le <Link> d'Inertia, qui produit une balise <a>. Vous obtenez un lien qui ressemble à un bouton, et qui navigue sans recharger la page, comme au chapitre 1.08.

create() vient de Wayfinder. Il construit l'adresse de la route admin.projects.create, soit /admin/projets/nouveau. Le kit utilise la même écriture dans sa barre latérale, avec SidebarMenuButton as-child.
6. La documentation comme ressource
Chaque composant a sa page sur le site de shadcn-vue, à l'adresse https://www.shadcn-vue.com/docs/components/ suivie de son nom, par exemple la page dialog. La page contient la commande d'installation, des exemples complets et la liste des morceaux du composant.
La documentation écrit ses commandes avec pnpm dlx, un autre gestionnaire de paquets. Remplacez pnpm dlx par npx, que vous utilisez déjà. Ses exemples sont en TypeScript, avec <script setup lang="ts">. Ils se recopient dans une page en JavaScript tant qu'ils n'ajoutent pas de types : retirez lang="ts" et les annotations comme : string si vous en trouvez.
Lisez un exemple avant de construire un composant à partir de rien. La page Dialog, par exemple, montre une boîte qui contient un formulaire, ce dont vous aurez besoin au chapitre 1.13. Le kit en contient aussi un, dans resources/js/components/DeleteUser.vue.
7. Pratique : le tableau d'administration (15 min)
Remplacez la liste brute du chapitre 1.10 par un tableau. Voici resources/js/pages/admin/projects/Index.vue :
<script setup>
import { Head, Link } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { create, index } from '@/routes/admin/projects';
defineOptions({
layout: {
breadcrumbs: [{ title: 'Projets', href: index() }],
},
});
defineProps({
projects: Array,
});
</script>
<template>
<Head title="Projets" />
<div class="space-y-4 p-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">Projets</h1>
<Button as-child>
<Link :href="create()">Nouveau projet</Link>
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Titre</TableHead>
<TableHead>Adresse</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="project in projects" :key="project.id">
<TableCell class="font-medium">{{ project.title }}</TableCell>
<TableCell>/projets/{{ project.slug }}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>

defineOptions({ layout: { breadcrumbs: [...] } }) passe une prop au layout, comme title dans les pages de connexion. AppLayout attend une prop breadcrumbs, la liste des étapes du fil d'Ariane, chacune avec un titre et une adresse. Le fil d'Ariane affiche maintenant « Projets » en haut de la page.
Chaque composant de la table produit sa balise HTML, avec les classes de shadcn-vue. TableRow répète la ligne avec v-for, et :key reçoit project.id, qui ne change jamais.
Vérifiez dans le navigateur.
/admin/projetsaffiche un tableau de deux colonnes avec vos trois projets, dans l'ordre deposition.- Le fil d'Ariane affiche « Projets ».
- Le bouton « Nouveau projet » a l'apparence d'un bouton, et l'outil d'inspection du navigateur montre une balise
<a href="/admin/projets/nouveau">. Le clic mène à une page d'erreur, car la méthodecreate()n'existe pas encore : elle arrive au chapitre 1.12. resources/js/components/ui/contient les dossierstableettextarea, etpackage.jsoncontientcn.
Ce que votre portfolio contient maintenant
Un tableau d'administration de vos projets, fait de composants shadcn-vue rangés dans votre dépôt, avec un bouton « Nouveau projet » qui attend sa page. Deux composants de plus, Table et Textarea, dans resources/js/components/ui/.
À retenir
- shadcn-vue copie le code de chaque composant dans
resources/js/components/ui/, et ce code vous appartient :Button.vueporte la structure,index.tsles classes. - Un composant Vue, c'est un fichier
.vueimporté par la page, des props déclarées pardefineProps, et un<slot />qui reçoit ce que la page écrit entre les balises. npx shadcn-vue@latest add table textareaajoute des composants en lisant les réglages decomponents.json.- Après une commande qui installe, vous lisez
package.jsonpour savoir ce qui a été ajouté. - La prop
variantchoisit l'apparence d'un bouton, et la propsizesa taille. <Button as-child>donne l'apparence d'un bouton à son enfant, par exemple un<Link>, sans produire de<button>.