shadcn-vue
Read when: starting a project with shadcn-vue, adding or updating a component, styling with its
tokens, or a registry component misbehaves (wrong side in Arabic, styles lost after add).
Owns: the CLI, what init writes, the day-one install, composition rules, and the map of where
the catalogue, the vendored folder, the theme, charts and RTL are covered.
shadcn-vue copies component source into your project (components/ui/), built on Reka UI and
Tailwind 4. You own the files, but the CLI can still overwrite them. CLI facts below were checked
against shadcn-vue 2.8.2 and shadcn-vue.com/docs (checked 2026-11-03).
TL;DR
- Check the registry before styling a
div.Button,Badge,Select,Separator,Empty,Alert,Skeletonexist; a hand-built one misses focus, dark mode, RTL and keyboard handling. - Never hand-edit
components/ui/. Customise through a wrapper incomponents/, aclassfor layout, or a token. Why: the CLI rewrites those files, and an edit there is lost without warning. - Read
git diffafter every CLI run.addcan rewrite the stylesheet and revert a component. - Semantic tokens only (
bg-background,text-muted-foreground,bg-primary/80). Why: each token is redefined under.dark; a literal colour is right in one theme only. - Logical classes only (
ms-,pe-,start-,text-start). Turnrtlon incomponents.jsonwhen the app runs in Arabic. - One icon package, the one
iconLibrarynames. Swap any other set a registry item brings. - Never pass
--overwriteor runapplywithout the user's yes. Both replace your component files.
Start a project
| Stack | How |
|---|---|
| New Laravel app | laravel new app --vue: the Vue starter kit ships Inertia, TypeScript, Tailwind 4 and shadcn-vue already initialised |
| Existing Vite, Laravel, Nuxt or Astro app | npx shadcn-vue@latest init (-t vite, -t laravel, -t nuxt or -t astro skips the question) |
| Arabic as a main language | add --rtl to init, or set "rtl": true later and run migrate rtl (see references/rtl.md) |
Use the project's runner: npx shadcn-vue@latest, pnpm dlx shadcn-vue@latest or
bunx --bun shadcn-vue@latest. create is an alias of init.
Useful init flags (checked 2026-11-03): --base-color (neutral, gray, zinc, stone, slate),
--style (vega, nova, maia, lyra, mira, luma, sera), --icon-library (lucide, tabler, hugeicons,
phosphor, remixicon), --font, --preset <name|code|url>, --rtl, --pointer (pointer cursor on
buttons), --no-reinstall. style, baseColor and cssVariables cannot change after init
without reinstalling every component, so pick them first.
What init writes
| File | What it holds |
|---|---|
components.json (project root) |
the CLI's config: style, stylesheet path, aliases, icon library, RTL |
<utils alias>.ts, usually lib/utils.ts |
the cn() helper |
the stylesheet tailwind.css points at |
Tailwind imports, the token variables for :root and .dark, the @theme inline mapping, a @layer base |
package.json |
reka-ui, class-variance-authority, clsx, tailwind-merge, tw-animate-css, the icon package, and shadcn-vue itself for newer styles |
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-nova",
"typescript": true,
"tailwind": {
"config": "",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}
tailwind.configstays""on Tailwind 4. A path there is a leftover from Tailwind 3.tailwind.cssisresources/css/app.cssin Laravel,assets/css/main.cssin Nuxt andsrc/style.cssin Vite. Every token is defined in that one file.- The aliases must also exist in
tsconfig.json(paths). Why: the CLI writes imports with them, and the type checker and Vite resolve them from there. - Newer styles import
shadcn-vue/tailwind.cssfrom the stylesheet (thedata-open:,data-checked:state variants), so keepshadcn-vueinpackage.jsonat the version that generated your components.
// lib/utils.ts
import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Then replace the stylesheet init wrote with assets/app.css and put
assets/fonts.css beside it: light and dark tokens, Arabic and Latin fonts, and
the unlayered fixes, ready to copy. references/theme.md explains each part.
Day one: one add
A typical app (sidebar shell, forms, tables, dialogs, toasts) installs this set on day one:
npx shadcn-vue@latest add alert alert-dialog avatar badge breadcrumb button card checkbox \
dialog dropdown-menu empty field input input-group label popover select separator sheet \
sidebar skeleton sonner spinner switch table tabs textarea tooltip
git diff # read the stylesheet hunk, put back anything it dropped
Add the rest when a screen needs it: command (searchable lists), chart, pagination,
toggle-group, accordion, collapsible, calendar, progress, scroll-area, input-otp.
Before adding, list components/ui/: re-adding an installed one is how edits get reverted.
In Nuxt the shadcn-nuxt module auto-imports the components; elsewhere import from
@/components/ui/<name>, which each folder's index.ts exports.
Composition rules
The registry expects these shapes. Breaking one gives a console warning, a broken keyboard path or an unlabelled dialog.
- Items live in their group:
SelectIteminSelectGroup,DropdownMenuIteminDropdownMenuGroup,CommandIteminCommandGroup.TabsTriggerlives inTabsList, never directly inTabs. - Every
Dialog,SheetandDrawerhas a Title (class="sr-only"if it should not show) and a Description. Why: screen readers announce them, and Reka warns without them. Avataralways hasAvatarFallback, for when the image fails.- Use the whole
Card: title and description inCardHeader, actions inCardFooter. - Forms are
FieldGroup>Field. Mark an error withdata-invalidonFieldandaria-invalidon the control, so both the label and the control turn red. - A control inside an
InputGroupisInputGroupInput/InputGroupTextarea, never a plainInput. Why: the group draws one border and focus ring; a plain input draws a second. Buttonhas no loading prop. Disable it and swap its icon for<Spinner />while busy.- An icon in a
Buttontakesdata-icon="inline-start"or"inline-end"and no size class. The button sizes it per size; asize-4you add freezes it. - No manual
z-indexon an overlay; they stack themselves. - Reka's
SelectItemrejects an empty value. Use a sentinel ('all') and map it back to''. - A link styled as a button takes
buttonVariants()on the router link (Inertia<Link>,<RouterLink>,<NuxtLink>), never a<Button>wrapped in a link.
Where each topic lives
| Working on… | Read |
|---|---|
| Which component for a need, layout classes, wrappers worth building | references/catalogue.md |
What add overwrites, updating a component, the reka-ui import rule, icons, customising through a wrapper |
references/vendored-folder.md |
| The stylesheet template, tokens, palette, radius, fonts (Arabic + Latin), dark mode, unlayered overrides, chart colours | references/theme.md |
| A chart (Unovis): composition, y domain, types | references/charts.md |
| Arabic: turning RTL on, registry gaps and their fixes | references/rtl.md |
Before calling it done
git diffshows no change undercomponents/ui/you did not mean, and the stylesheet kept its fonts and has one@layer base.- The type check passes (
vue-tsc --noEmit, ornuxi typecheck). - The new screen looks right in light and dark, and in Arabic if the app has it.