featured image

Building Reusable Vue Components with TypeScript and Tailwind

Learn how to create production-grade Vue 3 components using TypeScript, Tailwind CSS, and Class Variance Authority (CVA) for scalable and maintainable UI development.

Published

Wed Oct 15 2025

Technologies Used

Vue.js Typescript TailwindCSS
Beginner 29 minutes

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 values
  • computed(): 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:

  1. Use TypeScript for type safety and IntelliSense
  2. Implement CVA for managing component variants elegantly
  3. Leverage Tailwind for utility-first styling
  4. Follow accessibility best practices with ARIA attributes
  5. Compose with Reka UI for accessible primitives
  6. 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. 🎉

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!