Every UI component library starts the same way: someone needs a button. They write one. Then they need a slightly different button — different color, maybe a loading state. They copy the first one and change a few classes. Three buttons later, a bug gets fixed in one but not the others. Six months later, someone adds a new variant and can’t remember where all the old ones live.
The MyKpopLists component architecture avoids this by treating component variants as data, not duplicated code. The tools involved are Vue 3’s Composition API, TypeScript for type safety, Class Variance Authority (CVA) for managing variants, Reka UI for accessible primitives, and Tailwind CSS for styling. This tutorial walks through how they fit together to produce components that are type-checked, accessible, and trivial to extend.
What You Need Coming In
Basic Vue.js 3 knowledge (components, props, events), TypeScript basics (types and interfaces), familiarity with Tailwind CSS utility classes, and a basic understanding of accessibility concepts like ARIA attributes.
Environment from package.json: Vue 3.5+, TypeScript 5.2+, Vite 7.0+, Tailwind CSS 4.x, Reka UI 2.5+, Class Variance Authority 0.7+.
The Copy-Paste Button Problem
The obvious approach to multiple button styles is one file per variant:
<!-- 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>
This fails fast. When you add a loading state, you add it to three files. When you change the border radius, same. When you add a fourth variant, you copy one of the existing three and hope you remembered to update everything. TypeScript can’t help you because there’s no shared contract between the files.
CVA solves this by defining all variants in one place and computing the final class string at runtime.
TypeScript Interfaces Before Any Component Code
Before writing a single Vue component, I define the prop contracts in TypeScript:
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
}
These interfaces do three things: they give your IDE autocomplete when using the components, they cause a compile-time error if you pass variant="invalid", and they document the API without needing a separate docs page.
Building the Button with CVA
CVA’s cva() function takes base classes (applied to every variant) and a variant map (one class string per option per axis). The result is a function you call with your props to get the final class string:
const buttonVariants = cva(
'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: {
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: {
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',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
CVA also exports a VariantProps type that infers the allowed values from the variant map. This is what powers TypeScript’s error checking at the call site:
type ButtonVariants = VariantProps<typeof buttonVariants>
// TypeScript infers: { variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link', size?: 'sm' | 'md' | 'lg' | 'icon' }
The full button component:
<script setup lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
import { computed } from 'vue'
const buttonVariants = cva(
'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: {
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: {
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',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
}
)
type ButtonVariants = VariantProps<typeof buttonVariants>
interface Props extends ButtonVariants {
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<Props>(), {
type: 'button',
disabled: false,
loading: false,
})
const buttonClasses = computed(() =>
buttonVariants({ variant: props.variant, size: props.size })
)
</script>
<template>
<button :type="type" :disabled="disabled || loading" :class="buttonClasses">
<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>
<slot />
</button>
</template>
Usage is now type-checked at the call site:
<Button variant="danger" :loading="isDeleting" @click="deleteItem">Delete</Button>
<Button variant="invalid"> <!-- TypeScript error: not assignable -->
Cards with Named Slots
Slots let the card component define layout structure while leaving the content entirely up to the caller. Named slots with conditional rendering (v-if="$slots.header") mean the header border only appears when someone actually passes header content:
<script setup lang="ts">
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' },
}
)
interface Props extends VariantProps<typeof 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">
<div v-if="$slots.header" class="mb-4 border-b border-gray-200 pb-4">
<slot name="header" />
</div>
<slot />
<div v-if="$slots.footer" class="mt-4 border-t border-gray-200 pt-4">
<slot name="footer" />
</div>
</div>
</template>
Accessible Dialogs via Reka UI
Accessibility is the part most component libraries get wrong. Focus management, ARIA attributes, keyboard navigation — it’s easy to miss details that screen reader users depend on. Reka UI provides headless primitives that handle all of this correctly:
<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)">
<DialogTrigger as-child>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal>
<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" />
<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">
<DialogTitle v-if="title" class="text-lg font-semibold">{{ title }}</DialogTitle>
<DialogDescription v-if="description" class="mt-2 text-sm text-gray-600">{{ description }}</DialogDescription>
<div class="mt-4">
<slot />
</div>
<DialogClose class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400">
<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>
DialogPortal renders the dialog outside the normal DOM hierarchy, which prevents stacking context issues (z-index bugs where the dialog appears behind other elements). The Reka UI primitives handle focus trapping, Escape to close, and ARIA role management automatically.
Two Things That Will Save You Later
Class conflicts. If a caller passes class="bg-red-500" to a button that already has bg-blue-600 from CVA, the result is unpredictable — CSS order determines which wins. The fix is a cn() utility that uses tailwind-merge to intelligently resolve conflicts:
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// In the component
const buttonClasses = computed(() =>
cn(buttonVariants({ variant: props.variant }), props.class)
)
tailwind-merge understands Tailwind’s utility class semantics — it knows bg-red-500 and bg-blue-600 are in conflict and picks the last one rather than leaving both in the class string.
Event listener cleanup. Vue’s onUnmounted is the equivalent of React’s cleanup function, and it’s just as mandatory:
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
Or use VueUse’s useEventListener, which handles cleanup automatically. Either way, a listener without cleanup accumulates on every mount and causes increasingly strange behavior as the component is navigated to repeatedly.
The mental model that ties all of this together: components are a contract between the author and the caller. TypeScript enforces the prop contract at compile time. CVA enforces the variant contract at runtime. Reka UI enforces the accessibility contract in the browser. You write the structure once, and every use of the component inherits all three.