vue3-shadcn-tailwind
Vue 3 + shadcn-vue + Tailwind CSS
Vue 3 Composition API
Always use <script setup lang="ts"> syntax. This is the recommended and most concise way to write Vue 3 components.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
// Props with TypeScript
interface Props {
title: string
count?: number
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
})
// Emits with TypeScript
const emit = defineEmits<{
update: [value: string]
delete: [id: string]
}>()
// Reactive state
const isOpen = ref(false)
const doubled = computed(() => props.count * 2)
// Lifecycle
onMounted(() => {
// initialization
})
</script>
Key patterns
- v-model: Use
defineModel()for two-way binding (Vue 3.4+):<script setup lang="ts"> const modelValue = defineModel<string>({ required: true }) </script> - Template refs: Use
useTemplateRef()(Vue 3.5+) orref<HTMLElement | null>(null). - Provide / Inject: Use typed
InjectionKey<T>for type-safe dependency injection. - v-html is prohibited: Always use
{{ }}interpolation for XSS prevention. Never usev-html.
Composables
Composables are the primary pattern for reusable stateful logic. Prefix with use.
// composables/useToggle.ts
import { ref } from 'vue'
export function useToggle(initial = false) {
const value = ref(initial)
const toggle = () => { value.value = !value.value }
return { value, toggle }
}
shadcn-vue
shadcn-vue is a Vue port of shadcn/ui. Components are copied into your project (not installed as a dependency), giving full ownership and customization control.
Important: shadcn-vue is NOT shadcn/ui (React)
| Aspect | shadcn/ui (React) | shadcn-vue (Vue) |
|---|---|---|
| Primitives | Radix UI | Reka UI (formerly Radix Vue) |
| State binding | open={open} |
v-model:open="open" |
| Render delegation | asChild |
as-child (kebab-case) |
| Event handling | onClick |
@click |
| Conditional render | {condition && <X/>} |
v-if="condition" |
Never generate React-style shadcn/ui code. Always use Vue-specific patterns.
CLI commands
# Initialize shadcn-vue in a project
npx shadcn-vue@latest init
# Add a component (copies source files into your project)
npx shadcn-vue@latest add button
npx shadcn-vue@latest add dialog card input label
# Add multiple components at once
npx shadcn-vue@latest add button input label textarea select
# Show project configuration
npx shadcn-vue@latest info
Component import pattern
Components are always imported from @/components/ui/<component-name>:
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
</script>
The cn() utility
Used to merge Tailwind classes with conflict resolution. Located at @/lib/utils:
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Use cn() when building components that accept a class prop:
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('rounded-lg border p-4', props.class)">
<slot />
</div>
</template>
Common component usage patterns
Button variants:
<Button variant="default">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
<Button variant="link">Link</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
<Button size="icon"><IconComponent /></Button>
Dialog (modal) with v-model:
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button variant="outline">Open</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Title</DialogTitle>
<DialogDescription>Description text.</DialogDescription>
</DialogHeader>
<!-- content -->
<DialogFooter>
<DialogClose as-child>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button @click="handleSave">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
Form with Input and Label:
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" v-model="email" type="email" placeholder="you@example.com" />
</div>
<div class="grid gap-2">
<Label for="message">Message</Label>
<Textarea id="message" v-model="message" placeholder="Write something..." />
</div>
</div>
Badge:
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
For the full list of available components, import paths, and detailed usage, see references/shadcn-vue-components.md.
Customizing components
Since components are copied into your project, customize them directly:
- Style changes: Modify Tailwind classes in the component source
- Behavior changes: Edit the component logic directly
- New variants: Add to the
cva()variants definition in the component
// Example: adding a variant to button
const buttonVariants = cva(
'inline-flex items-center justify-center ...',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground ...',
// Add custom variant
success: 'bg-green-600 text-white hover:bg-green-700',
},
},
},
)
Tailwind CSS
Theming with CSS variables
shadcn-vue uses CSS variables for theming. Colors are defined in oklch format:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
Use semantic color names in Tailwind classes:
<!-- Do: Use semantic tokens -->
<div class="bg-background text-foreground border-border">
<p class="text-muted-foreground">
<button class="bg-primary text-primary-foreground">
<!-- Don't: Use raw colors when semantic tokens exist -->
<div class="bg-white text-black border-gray-200">
Responsive design
Mobile-first breakpoints:
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-4 sm:p-6 lg:p-8">
<div class="hidden md:block">
Common layout patterns
Card grid:
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Card items -->
</div>
Centered content with max-width:
<div class="mx-auto w-full max-w-2xl px-4">
<!-- Content -->
</div>
Flex with gap:
<div class="flex items-center gap-2">
<Icon />
<span>Text</span>
</div>
Stack (vertical spacing):
<div class="flex flex-col gap-4">
<!-- Stacked items -->
</div>
<!-- or -->
<div class="space-y-4">
<!-- Stacked items -->
</div>
Dark mode
shadcn-vue uses class-based dark mode. Toggle the dark class on <html>:
// Toggle dark mode
document.documentElement.classList.toggle('dark')
Dark-mode styles use the dark: variant:
<div class="bg-white dark:bg-slate-900">
When using shadcn-vue's CSS variable system, dark mode colors are automatically applied through the .dark selector — no dark: prefix needed for theme colors.
Common mistakes to avoid
- Using Radix Vue imports — shadcn-vue now uses Reka UI. Import from
reka-ui, notradix-vue. - React-style JSX patterns — Use
v-model:open,as-child,@click, not React equivalents. - Using
v-html— Prohibited for XSS prevention. Use{{ }}interpolation. - Hardcoded colors — Use semantic CSS variable tokens (
bg-primary, notbg-blue-600). - Missing
cn()in custom components — Always usecn()when accepting aclassprop to allow overrides. - Importing from package — shadcn-vue components are local files (
@/components/ui/), not npm imports.