On this page
- Purpose
- The Problem
- The Solution
- What You’ll Build
- Prerequisites & Tooling
- Knowledge Base
- Environment
- Setup Commands
- High-Level Architecture
- Component Composition Pattern
- The CVA Variant System
- LEGOs
- The Implementation
- Understanding the Naive Approach
- Setting Up TypeScript Interfaces
- Creating Variant Classes with CVA
- Using the Component
- Building a Card Component with Slots
- Composing with Reka UI Primitives
- Responsive Design with Tailwind
- Accessibility Best Practices
- Under the Hood
- How Vue’s Reactivity System Works
- CVA’s Class Resolution Algorithm
- Tailwind’s JIT Compiler
- TypeScript’s Type Inference
- Edge Cases & Pitfalls
- Class Name Conflicts
- Prop Drilling
- Memory Leaks with Event Listeners
- When to Use Composition API vs Options API
- XSS Prevention
- Testing Components
- Conclusion
- What You’ve Learned
- The Key Insights
- Next Steps
- Real-World Applications
Purpose
The Problem
You’re building a social platform with dozens of UI components: buttons, cards, modals, forms, navigation menus. Each component needs to:
- Support multiple visual variants (primary, secondary, danger)
- Handle different sizes (small, medium, large)
- Work with TypeScript for type safety
- Be accessible (ARIA attributes, keyboard navigation)
- Be consistent across the entire application
The naive approach: Copy-paste components and modify them for each use case.
<!-- ❌ NAIVE APPROACH - Duplicated code -->
<template>
<!-- PrimaryButton.vue -->
<button class="bg-blue-500 text-white px-4 py-2 rounded">
{{ label }}
</button>
<!-- SecondaryButton.vue -->
<button class="bg-gray-500 text-white px-4 py-2 rounded">
{{ label }}
</button>
<!-- DangerButton.vue -->
<button class="bg-red-500 text-white px-4 py-2 rounded">
{{ label }}
</button>
</template>
Problems:
- Code duplication: Same logic repeated across 10+ button variants
- Inconsistency: Easy to forget to update all variants when fixing bugs
- No type safety: Props can be any type, leading to runtime errors
- Maintenance nightmare: Changing button behavior requires updating multiple files
- Accessibility gaps: Easy to forget ARIA attributes in some variants
The Solution
We’re analyzing MyKpopLists’ component architecture using:
- Vue 3 Composition API for reactive logic
- TypeScript for type safety and IntelliSense
- Class Variance Authority (CVA) for variant management
- Reka UI for accessible component primitives
- Tailwind CSS for utility-first styling
What You’ll Build
This tutorial covers:
- Vue 3 Composition API with
<script setup> - TypeScript interfaces and type inference
- Class Variance Authority (CVA) for managing component variants
- Tailwind CSS utility classes and responsive design
- Accessibility best practices (ARIA, keyboard navigation)
- Component composition patterns
Prerequisites & Tooling
Knowledge Base
- Basic Vue.js 3 knowledge (components, props, events)
- Understanding of TypeScript basics (types, interfaces)
- Familiarity with Tailwind CSS utility classes
- Basic understanding of accessibility concepts
Environment
Based on the project’s package.json:
- Vue: 3.5+
- TypeScript: 5.2+
- Vite: 7.0+
- Tailwind CSS: 4.x
- Reka UI: 2.5+
- Class Variance Authority: 0.7+
Setup Commands
# Install dependencies
npm install
# Start development server
npm run dev
# Type check
npm run type-check
High-Level Architecture
Component Composition Pattern
graph TD
A[Base Component<br/>Reka UI Primitive] --> B[Styled Wrapper<br/>Tailwind + CVA]
B --> C[Application Component<br/>Business Logic]
C --> D[Page Component<br/>Layout & Data]
E[Props] --> B
E --> C
F[Slots] --> B
F --> C
G[Events] --> C
G --> D
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#f3e5f5
The CVA Variant System
graph LR
A[Component Call] --> B{Variant Props}
B --> C[Base Classes]
B --> D[Variant Classes]
B --> E[Size Classes]
B --> F[State Classes]
C --> G[CVA Resolver]
D --> G
E --> G
F --> G
G --> H[Final Class String]
H --> I[Rendered Component]
LEGOs
Think of building components like assembling LEGO sets:
- Base pieces (Reka UI): Pre-made, accessible primitives (like LEGO bricks)
- Styling layer (Tailwind + CVA): Color schemes and decorations (like LEGO stickers)
- Business logic (Composition API): How pieces connect and interact (like LEGO instructions)
- Final product (Your component): A unique creation built from standard parts
Just as LEGO pieces are reusable and combinable, your components should be composable and consistent.
The Implementation
Understanding the Naive Approach
Most developers start with inline styles or scattered classes:
<!-- ❌ NAIVE APPROACH -->
<template>
<button
:class="isPrimary ? 'bg-blue-500' : 'bg-gray-500'"
@click="handleClick"
>
{{ label }}
</button>
</template>
<script setup lang="ts">
defineProps<{
label: string
isPrimary?: boolean
}>()
const handleClick = () => {
// Logic here
}
</script>
Problems:
- Ternary operators become unreadable with multiple variants
- No centralized variant management
- Hard to maintain consistency
- Difficult to add new variants
Setting Up TypeScript Interfaces
First, define clear types for your component props:
// resources/js/types/components.ts
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
}
export interface CardProps {
variant?: 'default' | 'outlined' | 'elevated'
padding?: 'none' | 'sm' | 'md' | 'lg'
hoverable?: boolean
}
Benefits:
- IntelliSense in your IDE
- Compile-time error checking
- Self-documenting code
- Easier refactoring
Creating Variant Classes with CVA
Class Variance Authority (CVA) manages component variants elegantly:
// resources/js/components/ui/Button.vue
<script setup lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
import { computed } from 'vue'
// Define variants using CVA
const buttonVariants = cva(
// Base classes (always applied)
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
// Variant: Different visual styles
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600',
ghost: 'hover:bg-gray-100 hover:text-gray-900',
link: 'text-blue-600 underline-offset-4 hover:underline',
},
// Size: Different dimensions
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 py-2',
lg: 'h-11 px-8 text-lg',
icon: 'h-10 w-10',
},
},
// Default values
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
// Extract variant props type from CVA
type ButtonVariants = VariantProps<typeof buttonVariants>
// Define component props
interface Props extends ButtonVariants {
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
}
// Use withDefaults for default values
const props = withDefaults(defineProps<Props>(), {
type: 'button',
disabled: false,
loading: false,
})
// Compute final classes
const buttonClasses = computed(() =>
buttonVariants({
variant: props.variant,
size: props.size,
})
)
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="buttonClasses"
>
<!-- Loading spinner -->
<svg
v-if="loading"
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<!-- Button content -->
<slot />
</button>
</template>
Key concepts:
cva(): Creates a variant resolver function- Base classes: Applied to all variants
- Variant groups: Different styling options
defaultVariants: Fallback valuescomputed(): Reactive class calculation
Using the Component
Now your button component is reusable and type-safe:
<!-- Usage in any page or component -->
<script setup lang="ts">
import Button from '@/components/ui/Button.vue'
const handleSubmit = () => {
console.log('Form submitted!')
}
</script>
<template>
<!-- Primary button (default) -->
<Button @click="handleSubmit">
Save Changes
</Button>
<!-- Secondary button -->
<Button variant="secondary" size="sm">
Cancel
</Button>
<!-- Danger button with loading state -->
<Button variant="danger" :loading="isDeleting" @click="deleteItem">
Delete
</Button>
<!-- Ghost button (minimal styling) -->
<Button variant="ghost" size="lg">
Learn More
</Button>
<!-- Link-style button -->
<Button variant="link">
View Details
</Button>
</template>
TypeScript benefits:
// ✅ TypeScript catches errors at compile time
<Button variant="invalid"> // Error: Type '"invalid"' is not assignable
<Button size="xl"> // Error: Type '"xl"' is not assignable
// ✅ IntelliSense shows available options
<Button variant="|" // Shows: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link'
Building a Card Component with Slots
Slots allow flexible content composition:
<!-- resources/js/components/ui/Card.vue -->
<script setup lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
import { computed } from 'vue'
const cardVariants = cva(
'rounded-lg border transition-shadow',
{
variants: {
variant: {
default: 'bg-white border-gray-200',
outlined: 'bg-transparent border-gray-300',
elevated: 'bg-white border-gray-200 shadow-lg',
},
padding: {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
},
},
defaultVariants: {
variant: 'default',
padding: 'md',
},
}
)
type CardVariants = VariantProps<typeof cardVariants>
interface Props extends CardVariants {
hoverable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
hoverable: false,
})
const cardClasses = computed(() => {
const base = cardVariants({
variant: props.variant,
padding: props.padding,
})
const hover = props.hoverable ? 'hover:shadow-xl cursor-pointer' : ''
return `${base} ${hover}`
})
</script>
<template>
<div :class="cardClasses">
<!-- Header slot (optional) -->
<div v-if="$slots.header" class="mb-4 border-b border-gray-200 pb-4">
<slot name="header" />
</div>
<!-- Default slot (main content) -->
<slot />
<!-- Footer slot (optional) -->
<div v-if="$slots.footer" class="mt-4 border-t border-gray-200 pt-4">
<slot name="footer" />
</div>
</div>
</template>
Using named slots:
<template>
<Card variant="elevated" hoverable>
<template #header>
<h3 class="text-lg font-semibold">User Profile</h3>
</template>
<p>This is the main content of the card.</p>
<p>It can contain any HTML or components.</p>
<template #footer>
<Button variant="primary">Edit Profile</Button>
<Button variant="ghost">Cancel</Button>
</template>
</Card>
</template>
Composing with Reka UI Primitives
Reka UI provides accessible primitives. Let’s build a Dialog component:
<!-- resources/js/components/ui/Dialog.vue -->
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger,
} from 'reka-ui'
interface Props {
open?: boolean
title?: string
description?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
</script>
<template>
<DialogRoot :open="open" @update:open="emit('update:open', $event)">
<!-- Trigger slot -->
<DialogTrigger as-child>
<slot name="trigger" />
</DialogTrigger>
<!-- Portal renders outside DOM hierarchy -->
<DialogPortal>
<!-- Overlay (backdrop) -->
<DialogOverlay
class="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<!-- Dialog content -->
<DialogContent
class="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-gray-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
>
<!-- Title -->
<DialogTitle v-if="title" class="text-lg font-semibold">
{{ title }}
</DialogTitle>
<!-- Description -->
<DialogDescription v-if="description" class="mt-2 text-sm text-gray-600">
{{ description }}
</DialogDescription>
<!-- Main content -->
<div class="mt-4">
<slot />
</div>
<!-- Close button -->
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
Usage:
<script setup lang="ts">
import { ref } from 'vue'
import Dialog from '@/components/ui/Dialog.vue'
import Button from '@/components/ui/Button.vue'
const isOpen = ref(false)
</script>
<template>
<Dialog
v-model:open="isOpen"
title="Confirm Deletion"
description="This action cannot be undone."
>
<template #trigger>
<Button variant="danger">Delete Account</Button>
</template>
<div class="space-y-4">
<p>Are you sure you want to delete your account?</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" @click="isOpen = false">
Cancel
</Button>
<Button variant="danger" @click="handleDelete">
Yes, Delete
</Button>
</div>
</div>
</Dialog>
</template>
Responsive Design with Tailwind
Tailwind’s responsive utilities work seamlessly with CVA:
const cardVariants = cva(
'rounded-lg border',
{
variants: {
padding: {
responsive: 'p-4 md:p-6 lg:p-8', // Grows on larger screens
},
columns: {
responsive: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
},
},
}
)
Mobile-first approach:
<template>
<!-- Stack on mobile, side-by-side on desktop -->
<div class="flex flex-col md:flex-row gap-4">
<Card class="flex-1">Content 1</Card>
<Card class="flex-1">Content 2</Card>
</div>
<!-- Hide on mobile, show on desktop -->
<div class="hidden md:block">
Desktop-only content
</div>
<!-- Show on mobile, hide on desktop -->
<div class="block md:hidden">
Mobile-only content
</div>
</template>
Accessibility Best Practices
Always include proper ARIA attributes:
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
label: string
error?: string
required?: boolean
}
const props = defineProps<Props>()
const inputId = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
const errorId = computed(() => `error-${inputId.value}`)
</script>
<template>
<div class="space-y-2">
<!-- Label with proper association -->
<label
:for="inputId"
class="text-sm font-medium"
:class="{ 'after:content-[\'*\'] after:ml-0.5 after:text-red-500': required }"
>
{{ label }}
</label>
<!-- Input with ARIA attributes -->
<input
:id="inputId"
:aria-required="required"
:aria-invalid="!!error"
:aria-describedby="error ? errorId : undefined"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
:class="{ 'border-red-500': error }"
/>
<!-- Error message -->
<p
v-if="error"
:id="errorId"
class="text-sm text-red-600"
role="alert"
>
{{ error }}
</p>
</div>
</template>
Keyboard navigation:
<script setup lang="ts">
import { ref } from 'vue'
const items = ref(['Item 1', 'Item 2', 'Item 3'])
const selectedIndex = ref(0)
const handleKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
break
case 'ArrowUp':
event.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
break
case 'Enter':
event.preventDefault()
selectItem(selectedIndex.value)
break
}
}
</script>
<template>
<ul
role="listbox"
tabindex="0"
@keydown="handleKeydown"
class="focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<li
v-for="(item, index) in items"
:key="index"
role="option"
:aria-selected="index === selectedIndex"
:class="{ 'bg-blue-100': index === selectedIndex }"
class="px-4 py-2 cursor-pointer hover:bg-gray-100"
>
{{ item }}
</li>
</ul>
</template>
Under the Hood
How Vue’s Reactivity System Works
When you use computed(), Vue tracks dependencies:
const buttonClasses = computed(() =>
buttonVariants({
variant: props.variant, // Vue tracks this
size: props.size, // And this
})
)
// Internally, Vue does:
// 1. Creates a reactive effect
// 2. Runs the function, tracking property access
// 3. When props.variant changes, re-runs the function
// 4. Updates the DOM only if the result changed
Performance implications:
// ❌ BAD: Computed runs on every render
const classes = computed(() => {
console.log('Computing classes') // Logs on every change
return buttonVariants({ variant: props.variant })
})
// ✅ GOOD: Computed is cached
const classes = computed(() => buttonVariants({ variant: props.variant }))
// Only re-computes when props.variant actually changes
CVA’s Class Resolution Algorithm
CVA uses a smart merging strategy:
// Given:
const variants = cva('base-class', {
variants: {
variant: { primary: 'bg-blue-500' },
size: { md: 'px-4 py-2' }
}
})
// When you call:
variants({ variant: 'primary', size: 'md' })
// CVA does:
// 1. Start with base: ['base-class']
// 2. Add variant: ['base-class', 'bg-blue-500']
// 3. Add size: ['base-class', 'bg-blue-500', 'px-4', 'py-2']
// 4. Join: 'base-class bg-blue-500 px-4 py-2'
Tailwind’s JIT Compiler
Tailwind 4.x uses Just-In-Time compilation:
/* When you write: */
<div class="bg-blue-500 hover:bg-blue-700">
/* Tailwind generates (only used classes): */
.bg-blue-500 { background-color: rgb(59 130 246); }
.hover\:bg-blue-700:hover { background-color: rgb(29 78 216); }
/* Unused classes are never generated */
Bundle size impact:
- Traditional Tailwind: ~3MB CSS (all utilities)
- JIT Tailwind: ~10KB CSS (only used utilities)
- 300x smaller!
TypeScript’s Type Inference
TypeScript infers types from CVA:
const buttonVariants = cva('base', {
variants: {
variant: { primary: '...', secondary: '...' }
}
})
type ButtonVariants = VariantProps<typeof buttonVariants>
// TypeScript infers:
// {
// variant?: 'primary' | 'secondary'
// }
// This powers IntelliSense and type checking!
Edge Cases & Pitfalls
Class Name Conflicts
<!-- ❌ DANGEROUS: Conflicting classes -->
<Button class="bg-red-500"> <!-- Conflicts with variant's bg-blue-500 -->
Click Me
</Button>
<!-- Result: Unpredictable (depends on CSS order) -->
Solution: Use cn() helper (clsx + tailwind-merge):
// resources/js/utils/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
<script setup lang="ts">
import { cn } from '@/utils/cn'
const buttonClasses = computed(() =>
cn(
buttonVariants({ variant: props.variant }),
props.class // User classes override variants
)
)
</script>
Prop Drilling
<!-- ❌ BAD: Passing props through multiple levels -->
<GrandParent :user="user">
<Parent :user="user">
<Child :user="user">
{{ user.name }}
</Child>
</Parent>
</GrandParent>
Solution: Use provide/inject:
<!-- GrandParent.vue -->
<script setup lang="ts">
import { provide } from 'vue'
const user = ref({ name: 'Alice' })
provide('user', user) // Provide to all descendants
</script>
<!-- Child.vue (any level deep) -->
<script setup lang="ts">
import { inject } from 'vue'
const user = inject('user') // Access directly
</script>
Memory Leaks with Event Listeners
<!-- ❌ BAD: Manual event listener not cleaned up -->
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
window.addEventListener('resize', handleResize)
// Missing cleanup!
})
</script>
Solution: Use onUnmounted:
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// Or use VueUse composable:
import { useEventListener } from '@vueuse/core'
useEventListener(window, 'resize', handleResize) // Auto cleanup!
</script>
When to Use Composition API vs Options API
Use Composition API when:
- Building reusable logic (composables)
- Need TypeScript support
- Complex component with multiple concerns
- Want better code organization
Use Options API when:
- Simple components with minimal logic
- Team is more familiar with it
- Migrating from Vue 2
XSS Prevention
<!-- ❌ DANGEROUS: Rendering user input as HTML -->
<div v-html="userInput"></div>
<!-- ✅ SAFE: Escape by default -->
<div>{{ userInput }}</div>
<!-- ✅ SAFE: Sanitize if HTML is needed -->
<script setup lang="ts">
import DOMPurify from 'dompurify'
const sanitizedHTML = computed(() => DOMPurify.sanitize(userInput.value))
</script>
<div v-html="sanitizedHTML"></div>
Testing Components
// tests/components/Button.spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Button from '@/components/ui/Button.vue'
describe('Button', () => {
it('renders with default variant', () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' }
})
expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('bg-blue-600')
})
it('applies variant classes correctly', () => {
const wrapper = mount(Button, {
props: { variant: 'danger' },
slots: { default: 'Delete' }
})
expect(wrapper.classes()).toContain('bg-red-600')
})
it('emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('disables button when loading', () => {
const wrapper = mount(Button, {
props: { loading: true }
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
})
Conclusion
What You’ve Learned
You now understand how to build production-grade Vue components that:
- Use TypeScript for type safety and IntelliSense
- Implement CVA for managing component variants elegantly
- Leverage Tailwind for utility-first styling
- Follow accessibility best practices with ARIA attributes
- Compose with Reka UI for accessible primitives
- Handle edge cases like class conflicts and memory leaks
The Key Insights
Components are the building blocks of your application. Well-designed components are:
- Reusable: Work in multiple contexts
- Composable: Can be combined to create complex UIs
- Type-safe: Catch errors at compile time
- Accessible: Work for all users
- Maintainable: Easy to update and extend
The 80/20 rule: 20% of your components (buttons, cards, inputs) are used 80% of the time. Invest in making these rock-solid.
Next Steps
- Build a component library: Create a full set of reusable components
- Add Storybook: Document and showcase your components
- Implement theming: Support light/dark modes
- Add animations: Use Motion-v for smooth transitions
- Create composables: Extract reusable logic
Real-World Applications
This pattern is used by:
- Shadcn/ui: Popular component library using CVA
- Radix UI: Accessible component primitives
- Headless UI: Unstyled, accessible components
- Chakra UI: Component library with variant system
You’ve just learned how modern component libraries are built. This knowledge is directly applicable to any Vue 3 project. 🎉