name: vue-shadcn-frontend description: Build production-grade Vue 3 applications with shadcn-vue components. Use when the user asks to create Vue.js applications, components, pages, or dashboards using shadcn-vue, Radix Vue, or when building modern Vue frontends with Composition API, TypeScript, and Tailwind CSS. Covers project setup, component patterns, state management with Pinia, routing with Vue Router, form handling with VeeValidate + Zod, and data fetching with TanStack Query.
Vue.js + shadcn-vue Frontend Skill
Build modern, accessible Vue 3 applications using shadcn-vue components, Composition API, and TypeScript.
Tech Stack
- Vue 3 with Composition API and
<script setup> - TypeScript for type safety
- shadcn-vue for UI components (built on Radix Vue)
- Tailwind CSS for styling
- Pinia for state management
- Vue Router for navigation
- VeeValidate + Zod for form validation
- TanStack Query (Vue Query) for data fetching
Project Setup
# Create new project
npm create vite@latest my-app -- --template vue-ts
cd my-app
npm install
# Add Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
# Add shadcn-vue
npx shadcn-vue@latest init
# Add components as needed
npx shadcn-vue@latest add button card input form dialog
Core Patterns
Component Structure
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import type { ComponentProps } from '@/types'
// Props with defaults
const props = withDefaults(defineProps<{
title: string
disabled?: boolean
}>(), {
disabled: false
})
// Emits with typed payload
const emit = defineEmits<{
submit: [data: FormData]
cancel: []
}>()
// Reactive state
const isLoading = ref(false)
// Computed
const buttonText = computed(() =>
isLoading.value ? 'Loading...' : 'Submit'
)
// Methods
const handleSubmit = async () => {
isLoading.value = true
// ...
emit('submit', formData)
}
</script>
<template>
<div class="space-y-4">
<h2 class="text-2xl font-bold">{{ title }}</h2>
<Button :disabled="disabled || isLoading" @click="handleSubmit">
{{ buttonText }}
</Button>
</div>
</template>
Composables Pattern
// composables/useAsync.ts
import { ref, type Ref } from 'vue'
export function useAsync<T>(asyncFn: () => Promise<T>) {
const data: Ref<T | null> = ref(null)
const error: Ref<Error | null> = ref(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
data.value = await asyncFn()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
return { data, error, loading, execute }
}
shadcn-vue Components
Form with Validation
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
const schema = toTypedSchema(z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
}))
const { handleSubmit, isSubmitting } = useForm({
validationSchema: schema,
})
const onSubmit = handleSubmit(async (values) => {
console.log(values)
})
</script>
<template>
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</Button>
</form>
</template>
Dialog/Modal
<script setup lang="ts">
import { ref } from 'vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
const open = ref(false)
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>
Are you sure you want to proceed?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="open = false">Cancel</Button>
<Button @click="handleConfirm">Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
Data Table
<script setup lang="ts">
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
interface User {
id: string
name: string
email: string
role: string
}
defineProps<{ users: User[] }>()
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell class="font-medium">{{ user.name }}</TableCell>
<TableCell>{{ user.email }}</TableCell>
<TableCell>{{ user.role }}</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
State Management with Pinia
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
const displayName = computed(() => user.value?.name ?? 'Guest')
async function login(credentials: Credentials) {
const response = await api.login(credentials)
user.value = response.user
token.value = response.token
}
function logout() {
user.value = null
token.value = null
}
return { user, token, isAuthenticated, displayName, login, logout }
}, {
persist: true // with pinia-plugin-persistedstate
})
Data Fetching with TanStack Query
<script setup lang="ts">
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
// Fetch data
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
})
// Mutation with optimistic updates
const { mutate: createUser } = useMutation({
mutationFn: (newUser: CreateUserDto) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<UserList v-else :users="users" />
</template>
Routing with Vue Router
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/pages/Home.vue') },
{
path: '/dashboard',
component: () => import('@/pages/Dashboard.vue'),
meta: { requiresAuth: true }
},
{ path: '/login', component: () => import('@/pages/Login.vue') },
]
})
// Navigation guard
router.beforeEach((to) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
return '/login'
}
})
export default router
Additional References
- Component Patterns: See references/component-patterns.md for advanced component patterns, slots, provide/inject, and compound components
- shadcn-vue Components: See references/shadcn-components.md for complete component API reference
- Testing: See references/testing.md for Vitest + Vue Test Utils patterns