Agent Skills: Vue.js Best Practices

Vue.js 3 best practices guidelines covering Composition API, component design, reactivity patterns, Tailwind CSS utility-first styling, PrimeVue component library integration, and code organization. This skill should be used when writing, reviewing, or refactoring Vue.js code to ensure idiomatic patterns and maintainable code.

UncategorizedID: dedalus-erp-pas/foundation-skills/vue-best-practices

Install this agent skill to your local

pnpm dlx add-skill https://github.com/Dedalus-ERP-PAS/foundation-skills/tree/HEAD/skills/vue-best-practices

Skill Files

Browse the full folder contents for vue-best-practices.

Download Skill

Loading file tree…

skills/vue-best-practices/SKILL.md

Skill Metadata

Name
vue-best-practices
Description
Vue.js 3 best practices guidelines covering Composition API, component design, reactivity patterns, Tailwind CSS utility-first styling, PrimeVue component library integration, and code organization. This skill should be used when writing, reviewing, or refactoring Vue.js code to ensure idiomatic patterns and maintainable code.

Vue.js Best Practices

Comprehensive best practices guide for Vue.js 3 applications. Contains guidelines across multiple categories to ensure idiomatic, maintainable, and scalable Vue.js code, including Tailwind CSS integration patterns for utility-first styling and PrimeVue component library best practices.

When to Apply

Reference these guidelines when:

  • Writing new Vue components or composables
  • Implementing features with Composition API
  • Reviewing code for Vue.js patterns compliance
  • Refactoring existing Vue.js code
  • Setting up component architecture
  • Working with Nuxt.js applications
  • Styling Vue components with Tailwind CSS utility classes
  • Creating design systems with Tailwind and Vue
  • Using PrimeVue component library
  • Customizing PrimeVue components with PassThrough API

Rule Categories

| Category | Focus | Prefix | |----------|-------|--------| | Composition API | Proper use of Composition API patterns | composition- | | Component Design | Component structure and organization | component- | | Reactivity | Reactive state management patterns | reactive- | | Props & Events | Component communication patterns | props- | | Template Patterns | Template syntax best practices | template- | | Code Organization | Project and code structure | organization- | | TypeScript | Type-safe Vue.js patterns | typescript- | | Error Handling | Error boundaries and handling | error- | | Tailwind CSS | Utility-first styling patterns | tailwind- | | PrimeVue | Component library integration patterns | primevue- |

Quick Reference

1. Composition API Best Practices

  • composition-script-setup - Always use <script setup> for single-file components
  • composition-ref-vs-reactive - Use ref() for primitives, reactive() for objects
  • composition-computed-derived - Use computed() for all derived state
  • composition-watch-side-effects - Use watch()/watchEffect() only for side effects
  • composition-composables - Extract reusable logic into composables
  • composition-lifecycle-order - Place lifecycle hooks after reactive state declarations
  • composition-avoid-this - Never use this in Composition API

2. Component Design

  • component-single-responsibility - One component, one purpose
  • component-naming-convention - Use PascalCase for components, kebab-case in templates
  • component-small-focused - Keep components under 200 lines
  • component-presentational-container - Separate logic from presentation when beneficial
  • component-slots-flexibility - Use slots for flexible component composition
  • component-expose-minimal - Only expose what's necessary via defineExpose()

3. Reactivity Patterns

  • reactive-const-refs - Always declare refs with const
  • reactive-unwrap-template - Let Vue unwrap refs in templates (no .value)
  • reactive-shallow-large-data - Use shallowRef()/shallowReactive() for large non-reactive data
  • reactive-readonly-props - Use readonly() to prevent mutations
  • reactive-toRefs-destructure - Use toRefs() when destructuring reactive objects
  • reactive-avoid-mutation - Prefer immutable updates for complex state

4. Props & Events

  • props-define-types - Always define prop types with defineProps<T>()
  • props-required-explicit - Be explicit about required vs optional props
  • props-default-values - Provide sensible defaults with withDefaults()
  • props-immutable - Never mutate props directly
  • props-validation - Use validator functions for complex prop validation
  • events-define-emits - Always define emits with defineEmits<T>()
  • events-naming - Use kebab-case for event names in templates
  • events-payload-objects - Pass objects for events with multiple values

5. Template Patterns

  • template-v-if-v-show - Use v-if for conditional rendering, v-show for toggling
  • template-v-for-key - Always use unique, stable :key with v-for
  • template-v-if-v-for - Never use v-if and v-for on the same element
  • template-computed-expressions - Move complex expressions to computed properties
  • template-event-modifiers - Use event modifiers (.prevent, .stop) appropriately
  • template-v-bind-shorthand - Use shorthand syntax (: for v-bind, @ for v-on)
  • template-v-model-modifiers - Use v-model modifiers (.trim, .number, .lazy)

6. Code Organization

  • organization-feature-folders - Organize by feature, not by type
  • organization-composables-folder - Keep composables in dedicated composables/ folder
  • organization-barrel-exports - Use index files for clean imports
  • organization-consistent-naming - Follow consistent naming conventions
  • organization-colocation - Colocate related files (component, tests, styles)

7. TypeScript Integration

  • typescript-generic-components - Use generics for reusable typed components
  • typescript-prop-types - Use TypeScript interfaces for prop definitions
  • typescript-emit-types - Type emit payloads explicitly
  • typescript-ref-typing - Specify types for refs when not inferred
  • typescript-template-refs - Type template refs with ref<InstanceType<typeof Component> | null>(null)

8. Error Handling

  • error-boundaries - Use onErrorCaptured() for component error boundaries
  • error-async-handling - Handle errors in async operations explicitly
  • error-provide-fallbacks - Provide fallback UI for error states
  • error-logging - Log errors appropriately for debugging

9. Tailwind CSS

  • tailwind-utility-first - Apply utility classes directly in templates, avoid custom CSS
  • tailwind-class-order - Use consistent class ordering (layout → spacing → typography → visual)
  • tailwind-responsive-mobile-first - Use mobile-first responsive design (sm:, md:, lg:)
  • tailwind-component-extraction - Extract repeated utility patterns into Vue components
  • tailwind-dynamic-classes - Use computed properties or helper functions for dynamic classes
  • tailwind-complete-class-strings - Always use complete class strings, never concatenate
  • tailwind-state-variants - Use state variants (hover:, focus:, active:) for interactions
  • tailwind-dark-mode - Use dark: prefix for dark mode support
  • tailwind-design-tokens - Configure design tokens in Tailwind config for consistency
  • tailwind-avoid-apply-overuse - Limit @apply usage; prefer Vue components for abstraction

10. PrimeVue

  • primevue-design-tokens - Use design tokens over CSS overrides for theming
  • primevue-passthrough-api - Use PassThrough (pt) API for component customization
  • primevue-wrapper-components - Wrap PrimeVue components for consistent styling across apps
  • primevue-unstyled-mode - Use unstyled mode with Tailwind for full styling control
  • primevue-global-pt-config - Define shared PassThrough properties at app level
  • primevue-merge-strategies - Choose appropriate merge strategies for PT customization
  • primevue-use-passthrough-utility - Use usePassThrough for extending presets
  • primevue-typed-components - Leverage PrimeVue's TypeScript support for type safety
  • primevue-accessibility - Maintain WCAG compliance with proper aria attributes
  • primevue-lazy-loading - Use async components for large PrimeVue imports

Key Principles

Composition API Best Practices

The Composition API is the recommended approach for Vue.js 3. Follow these patterns:

  • Always use <script setup>: More concise, better TypeScript inference, and improved performance
  • Organize code by logical concern: Group related state, computed properties, and functions together
  • Extract reusable logic to composables: Follow the use prefix convention (e.g., useAuth, useFetch)
  • Keep setup code readable: Order: props/emits, reactive state, computed, watchers, methods, lifecycle hooks

Component Design Principles

Well-designed components are the foundation of maintainable Vue applications:

  • Single Responsibility: Each component should do one thing well
  • Props Down, Events Up: Follow unidirectional data flow
  • Prefer Composition over Inheritance: Use composables and slots for code reuse
  • Keep Components Small: If a component exceeds 200 lines, consider splitting it

Reactivity Guidelines

Understanding Vue's reactivity system is crucial:

  • ref vs reactive: Use ref() for primitives and values you'll reassign; use reactive() for objects you'll mutate
  • Computed for derived state: Never store derived state in refs; use computed() instead
  • Watch for side effects: Only use watch() for side effects like API calls or localStorage
  • Be mindful of reactivity loss: Don't destructure reactive objects without toRefs()

Props & Events Patterns

Proper component communication ensures maintainable code:

  • Type your props: Use TypeScript interfaces with defineProps<T>()
  • Validate complex props: Use validator functions for business logic validation
  • Emit typed events: Use defineEmits<T>() for type-safe event handling
  • Use v-model for two-way binding: Implement modelValue prop and update:modelValue emit

Common Patterns

Script Setup Structure

Recommended structure for <script setup>:

<script setup lang="ts">
// 1. Imports
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import type { User } from '@/types'

// 2. Props and Emits
const props = defineProps<{
  userId: string
  initialData?: User
}>()

const emit = defineEmits<{
  submit: [user: User]
  cancel: []
}>()

// 3. Composables
const router = useRouter()
const { user, loading, error } = useUser(props.userId)

// 4. Reactive State
const formData = ref({ name: '', email: '' })
const isEditing = ref(false)

// 5. Computed Properties
const isValid = computed(() => {
  return formData.value.name.length > 0 && formData.value.email.includes('@')
})

// 6. Watchers (for side effects only)
watch(() => props.userId, (newId) => {
  fetchUserData(newId)
})

// 7. Methods
function handleSubmit() {
  if (isValid.value) {
    emit('submit', formData.value)
  }
}

// 8. Lifecycle Hooks
onMounted(() => {
  if (props.initialData) {
    formData.value = { ...props.initialData }
  }
})
</script>

Composable Pattern

Correct: Well-structured composable

// composables/useUser.ts
import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue'
import type { User } from '@/types'

export function useUser(userId: Ref<string> | string) {
  // State
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  // Computed
  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.firstName} ${user.value.lastName}`
  })

  // Methods
  async function fetchUser(id: string) {
    loading.value = true
    error.value = null
    try {
      const response = await api.getUser(id)
      user.value = response.data
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  // Auto-fetch when userId changes (if reactive)
  if (isRef(userId)) {
    watch(userId, (newId) => fetchUser(newId), { immediate: true })
  } else {
    fetchUser(userId)
  }

  // Return
  return {
    user: readonly(user),
    fullName,
    loading: readonly(loading),
    error: readonly(error),
    refresh: () => fetchUser(unref(userId))
  }
}

Props with Defaults

Correct: Typed props with defaults

<script setup lang="ts">
interface Props {
  title: string
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  items?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  disabled: false,
  items: () => []  // Use factory function for arrays/objects
})
</script>

Event Handling

Correct: Typed emits with payloads

<script setup lang="ts">
interface FormData {
  name: string
  email: string
}

const emit = defineEmits<{
  submit: [data: FormData]
  cancel: []
  'update:modelValue': [value: string]
}>()

function handleSubmit(data: FormData) {
  emit('submit', data)
}
</script>

v-model Implementation

Correct: Custom v-model with defineModel (Vue 3.4+)

<script setup lang="ts">
const model = defineModel<string>({ required: true })

// Or with default
const modelWithDefault = defineModel<string>({ default: '' })
</script>

<template>
  <input :value="model" @input="model = $event.target.value" />
</template>

Correct: Custom v-model (Vue 3.3 and earlier)

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const value = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})
</script>

<template>
  <input v-model="value" />
</template>

Template Ref Typing

Correct: Typed template refs

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import MyComponent from './MyComponent.vue'

// DOM element ref
const inputRef = ref<HTMLInputElement | null>(null)

// Component ref
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)

onMounted(() => {
  inputRef.value?.focus()
  componentRef.value?.someExposedMethod()
})
</script>

<template>
  <input ref="inputRef" />
  <MyComponent ref="componentRef" />
</template>

Provide/Inject with Types

Correct: Type-safe provide/inject

// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
import type { User } from './user'

export const UserKey: InjectionKey<Ref<User>> = Symbol('user')

// Parent component
import { provide, ref } from 'vue'
import { UserKey } from '@/types/injection-keys'

const user = ref<User>({ id: '1', name: 'John' })
provide(UserKey, user)

// Child component
import { inject } from 'vue'
import { UserKey } from '@/types/injection-keys'

const user = inject(UserKey)
if (!user) {
  throw new Error('User not provided')
}

Error Boundary Component

Correct: Error boundary with onErrorCaptured

<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const error = ref<Error | null>(null)

onErrorCaptured((err) => {
  error.value = err
  // Return false to stop error propagation
  return false
})

function reset() {
  error.value = null
}
</script>

<template>
  <div v-if="error" class="error-boundary">
    <p>Something went wrong: {{ error.message }}</p>
    <button @click="reset">Try again</button>
  </div>
  <slot v-else />
</template>

Async Component Loading

Correct: Async components with loading/error states

import { defineAsyncComponent } from 'vue'

const AsyncDashboard = defineAsyncComponent({
  loader: () => import('./Dashboard.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,  // Show loading after 200ms
  timeout: 10000  // Timeout after 10s
})

Tailwind CSS Best Practices

Vue's component-based architecture pairs naturally with Tailwind's utility-first approach. Follow these patterns for maintainable, consistent styling.

Utility-First Approach

Apply Tailwind utility classes directly in Vue templates for rapid, consistent styling:

Correct: Utility classes in template

<template>
  <div class="mx-auto max-w-md rounded-xl bg-white p-6 shadow-lg">
    <h2 class="text-xl font-semibold text-gray-900">{{ title }}</h2>
    <p class="mt-2 text-gray-600">{{ description }}</p>
    <button class="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
      {{ buttonText }}
    </button>
  </div>
</template>

Class Ordering Convention

Maintain consistent class ordering for readability. Recommended order:

  1. Layout - flex, grid, block, hidden
  2. Positioning - relative, absolute, fixed
  3. Box Model - w-, h-, m-, p-
  4. Typography - text-, font-, leading-
  5. Visual - bg-, border-, rounded-, shadow-
  6. Interactive - hover:, focus:, active:

Use the official Prettier plugin (prettier-plugin-tailwindcss) to automatically sort classes.

Responsive Design (Mobile-First)

Use Tailwind's responsive prefixes for mobile-first responsive design:

Correct: Mobile-first responsive layout

<template>
  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
    <article
      v-for="item in items"
      :key="item.id"
      class="p-4 text-sm sm:p-6 sm:text-base lg:text-lg"
    >
      <h3 class="font-medium">{{ item.title }}</h3>
    </article>
  </div>
</template>

Breakpoint Reference:

  • sm: - 640px and up
  • md: - 768px and up
  • lg: - 1024px and up
  • xl: - 1280px and up
  • 2xl: - 1536px and up

State Variants

Use state variants for interactive elements:

Correct: State variants for buttons

<template>
  <button
    class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white
           transition-colors duration-150
           hover:bg-indigo-700
           focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2
           active:bg-indigo-800
           disabled:cursor-not-allowed disabled:opacity-50"
    :disabled="isLoading"
  >
    {{ isLoading ? 'Loading...' : 'Submit' }}
  </button>
</template>

Dark Mode Support

Use the dark: prefix for dark mode styles:

Correct: Dark mode support

<template>
  <div class="bg-white dark:bg-gray-900">
    <h1 class="text-gray-900 dark:text-white">{{ title }}</h1>
    <p class="text-gray-600 dark:text-gray-400">{{ content }}</p>
    <div class="border-gray-200 dark:border-gray-700 rounded-lg border p-4">
      <slot />
    </div>
  </div>
</template>

Dynamic Classes with Computed Properties

Use computed properties for conditional class binding:

Correct: Computed classes for variants

<script setup lang="ts">
import { computed } from 'vue'

type ButtonVariant = 'primary' | 'secondary' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'

const props = withDefaults(defineProps<{
  variant?: ButtonVariant
  size?: ButtonSize
}>(), {
  variant: 'primary',
  size: 'md'
})

const variantClasses = computed(() => {
  const variants: Record<ButtonVariant, string> = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
    secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500'
  }
  return variants[props.variant]
})

const sizeClasses = computed(() => {
  const sizes: Record<ButtonSize, string> = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
    lg: 'px-6 py-3 text-lg'
  }
  return sizes[props.size]
})

const buttonClasses = computed(() => [
  'inline-flex items-center justify-center rounded-md font-medium',
  'transition-colors duration-150',
  'focus:outline-none focus:ring-2 focus:ring-offset-2',
  variantClasses.value,
  sizeClasses.value
])
</script>

<template>
  <button :class="buttonClasses">
    <slot />
  </button>
</template>

Class Variance Authority (CVA) Pattern

For complex component variants, use the CVA pattern with a helper library:

Correct: CVA-style variant management

<script setup lang="ts">
import { computed } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'

const button = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
  {
    variants: {
      intent: {
        primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
        secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
        danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500'
      },
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2 text-base',
        lg: 'px-6 py-3 text-lg'
      }
    },
    defaultVariants: {
      intent: 'primary',
      size: 'md'
    }
  }
)

type ButtonProps = VariantProps<typeof button>

const props = defineProps<{
  intent?: ButtonProps['intent']
  size?: ButtonProps['size']
}>()

const classes = computed(() => button({ intent: props.intent, size: props.size }))
</script>

<template>
  <button :class="classes">
    <slot />
  </button>
</template>

Component Extraction for Reusable Patterns

Extract repeated utility patterns into Vue components:

Correct: Reusable card component

<!-- components/BaseCard.vue -->
<script setup lang="ts">
withDefaults(defineProps<{
  padding?: 'none' | 'sm' | 'md' | 'lg'
  shadow?: 'none' | 'sm' | 'md' | 'lg'
}>(), {
  padding: 'md',
  shadow: 'md'
})
</script>

<template>
  <div
    class="rounded-xl bg-white dark:bg-gray-800"
    :class="[
      {
        'p-0': padding === 'none',
        'p-4': padding === 'sm',
        'p-6': padding === 'md',
        'p-8': padding === 'lg'
      },
      {
        'shadow-none': shadow === 'none',
        'shadow-sm': shadow === 'sm',
        'shadow-md': shadow === 'md',
        'shadow-lg': shadow === 'lg'
      }
    ]"
  >
    <slot />
  </div>
</template>

Tailwind Configuration with Design Tokens

Define design tokens in your Tailwind config for consistency:

Correct: tailwind.config.js with design tokens

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        // Semantic color tokens
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8'
        },
        surface: {
          light: '#ffffff',
          dark: '#1f2937'
        }
      },
      spacing: {
        // Custom spacing tokens
        '4.5': '1.125rem',
        '18': '4.5rem'
      },
      fontFamily: {
        sans: ['Inter', 'system-ui', 'sans-serif']
      },
      borderRadius: {
        '4xl': '2rem'
      }
    }
  },
  plugins: []
}

Tailwind CSS v4 Configuration

For Tailwind CSS v4, use the CSS-first configuration approach:

Correct: Tailwind v4 CSS configuration

/* main.css */
@import "tailwindcss";

@theme {
  /* Custom colors */
  --color-primary-500: #3b82f6;
  --color-primary-600: #2563eb;
  --color-primary-700: #1d4ed8;

  /* Custom spacing */
  --spacing-4-5: 1.125rem;
  --spacing-18: 4.5rem;

  /* Custom fonts */
  --font-family-sans: 'Inter', system-ui, sans-serif;
}

Using cn() Helper for Conditional Classes

Use a class merging utility for conditional classes:

Correct: cn() helper with clsx and tailwind-merge

// utils/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Usage in component:

<script setup lang="ts">
import { cn } from '@/utils/cn'

const props = defineProps<{
  class?: string
  isActive?: boolean
}>()
</script>

<template>
  <div
    :class="cn(
      'rounded-lg border p-4 transition-colors',
      isActive ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white',
      props.class
    )"
  >
    <slot />
  </div>
</template>

PrimeVue Best Practices

PrimeVue is a comprehensive Vue UI component library with 90+ components. Follow these patterns for effective integration and customization.

Installation & Setup

Correct: PrimeVue v4 setup with Vue 3

// main.ts
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import App from './App.vue'

const app = createApp(App)

app.use(PrimeVue, {
  theme: {
    preset: Aura,
    options: {
      darkModeSelector: '.dark-mode'
    }
  }
})

app.mount('#app')

Correct: Component registration (tree-shakeable)

// main.ts - Register only components you use
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'

app.component('Button', Button)
app.component('DataTable', DataTable)
app.component('Column', Column)

PassThrough (PT) API

The PassThrough API allows customization of internal DOM elements without modifying component source:

Correct: Component-level PassThrough

<script setup lang="ts">
import Panel from 'primevue/panel'
</script>

<template>
  <Panel
    header="User Profile"
    toggleable
    :pt="{
      header: {
        class: 'bg-primary-100 dark:bg-primary-900'
      },
      content: {
        class: 'p-6'
      },
      title: {
        class: 'text-xl font-semibold'
      },
      toggler: {
        class: 'hover:bg-primary-200 dark:hover:bg-primary-800 rounded-full'
      }
    }"
  >
    <p>Panel content here</p>
  </Panel>
</template>

Correct: Dynamic PassThrough with state

<script setup lang="ts">
import Panel from 'primevue/panel'
</script>

<template>
  <Panel
    header="Collapsible Panel"
    toggleable
    :pt="{
      header: (options) => ({
        class: [
          'transition-colors duration-200',
          {
            'bg-primary-500 text-white': options.state.d_collapsed,
            'bg-surface-100 dark:bg-surface-800': !options.state.d_collapsed
          }
        ]
      })
    }"
  >
    <p>Content changes header style when collapsed</p>
  </Panel>
</template>

Global PassThrough Configuration

Define shared styles at the application level:

Correct: Global PT configuration

// main.ts
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'

app.use(PrimeVue, {
  theme: {
    preset: Aura
  },
  pt: {
    // All buttons get consistent styling
    button: {
      root: {
        class: 'rounded-lg font-medium transition-all duration-200'
      }
    },
    // All inputs get consistent styling
    inputtext: {
      root: {
        class: 'rounded-lg border-2 focus:ring-2 focus:ring-primary-500'
      }
    },
    // All panels share styling
    panel: {
      header: {
        class: 'bg-surface-50 dark:bg-surface-900'
      }
    },
    // Global CSS injection
    global: {
      css: `
        .p-component {
          font-family: 'Inter', sans-serif;
        }
      `
    }
  }
})

usePassThrough Utility

Extend existing presets with custom modifications:

Correct: Extending Tailwind preset

// presets/custom-tailwind.ts
import { usePassThrough } from 'primevue/passthrough'
import Tailwind from 'primevue/passthrough/tailwind'

export const CustomTailwind = usePassThrough(
  Tailwind,
  {
    panel: {
      header: {
        class: ['bg-gradient-to-r from-primary-500 to-primary-600']
      },
      title: {
        class: ['text-white font-bold']
      }
    },
    button: {
      root: {
        class: ['shadow-lg hover:shadow-xl transition-shadow']
      }
    }
  },
  {
    mergeSections: true,  // Keep original sections
    mergeProps: false     // Replace props (don't merge arrays)
  }
)

Merge Strategy Reference:

| mergeSections | mergeProps | Behavior | |---------------|------------|----------| | true | false | Custom value replaces original (default) | | true | true | Custom values merge with original | | false | true | Only custom sections included | | false | false | Minimal - only custom sections, no merging |

Unstyled Mode with Tailwind

Use unstyled PrimeVue components with full Tailwind control:

Correct: Unstyled mode configuration

// main.ts
import PrimeVue from 'primevue/config'

app.use(PrimeVue, {
  unstyled: true  // Remove all default styles
})

Correct: Custom styled button with unstyled mode

<script setup lang="ts">
import Button from 'primevue/button'
</script>

<template>
  <Button
    label="Submit"
    :pt="{
      root: {
        class: [
          'inline-flex items-center justify-center',
          'px-4 py-2 rounded-lg font-medium',
          'bg-primary-600 text-white',
          'hover:bg-primary-700 active:bg-primary-800',
          'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
          'transition-colors duration-150',
          'disabled:opacity-50 disabled:cursor-not-allowed'
        ]
      },
      label: {
        class: 'font-medium'
      },
      icon: {
        class: 'mr-2'
      }
    }"
    :ptOptions="{ mergeSections: false, mergeProps: false }"
  />
</template>

Wrapper Components Pattern

Create reusable wrapper components for consistent styling:

Correct: Button wrapper component

<!-- components/ui/AppButton.vue -->
<script setup lang="ts">
import Button from 'primevue/button'

type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'md' | 'lg'

const props = withDefaults(defineProps<{
  variant?: ButtonVariant
  size?: ButtonSize
  loading?: boolean
}>(), {
  variant: 'primary',
  size: 'md',
  loading: false
})

const variantClasses: Record<ButtonVariant, string> = {
  primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
  secondary: 'bg-surface-200 text-surface-900 hover:bg-surface-300 focus:ring-surface-500',
  danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
  ghost: 'bg-transparent text-primary-600 hover:bg-primary-50 focus:ring-primary-500'
}

const sizeClasses: Record<ButtonSize, string> = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg'
}
</script>

<template>
  <Button
    v-bind="$attrs"
    :loading="loading"
    :pt="{
      root: {
        class: [
          'inline-flex items-center justify-center rounded-lg font-medium',
          'transition-all duration-200',
          'focus:outline-none focus:ring-2 focus:ring-offset-2',
          'disabled:opacity-50 disabled:cursor-not-allowed',
          variantClasses[variant],
          sizeClasses[size]
        ]
      }
    }"
    :ptOptions="{ mergeSections: false, mergeProps: false }"
  >
    <slot />
  </Button>
</template>

<script lang="ts">
export default {
  inheritAttrs: false
}
</script>

Usage:

<template>
  <AppButton variant="primary" size="lg" @click="handleSubmit">
    Submit Form
  </AppButton>
  <AppButton variant="ghost" size="sm">
    Cancel
  </AppButton>
</template>

DataTable Best Practices

Correct: Typed DataTable with Composition API

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'

interface User {
  id: number
  name: string
  email: string
  role: string
  status: 'active' | 'inactive'
}

const users = ref<User[]>([])
const loading = ref(true)
const selectedUsers = ref<User[]>([])

// Pagination
const first = ref(0)
const rows = ref(10)

// Sorting
const sortField = ref<string>('name')
const sortOrder = ref<1 | -1>(1)

onMounted(async () => {
  loading.value = true
  try {
    users.value = await fetchUsers()
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <DataTable
    v-model:selection="selectedUsers"
    :value="users"
    :loading="loading"
    :paginator="true"
    :rows="rows"
    :first="first"
    :sortField="sortField"
    :sortOrder="sortOrder"
    dataKey="id"
    stripedRows
    removableSort
    @page="(e) => first = e.first"
    @sort="(e) => { sortField = e.sortField; sortOrder = e.sortOrder }"
  >
    <Column selectionMode="multiple" headerStyle="width: 3rem" />
    <Column field="name" header="Name" sortable />
    <Column field="email" header="Email" sortable />
    <Column field="role" header="Role" sortable />
    <Column field="status" header="Status">
      <template #body="{ data }">
        <span
          :class="[
            'px-2 py-1 rounded-full text-xs font-medium',
            data.status === 'active'
              ? 'bg-green-100 text-green-800'
              : 'bg-red-100 text-red-800'
          ]"
        >
          {{ data.status }}
        </span>
      </template>
    </Column>
  </DataTable>
</template>

Form Components Pattern

Correct: Form with validation using PrimeVue

<script setup lang="ts">
import { ref, computed } from 'vue'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Dropdown from 'primevue/dropdown'
import Button from 'primevue/button'
import Message from 'primevue/message'

interface FormData {
  email: string
  password: string
  role: string | null
}

const formData = ref<FormData>({
  email: '',
  password: '',
  role: null
})

const errors = ref<Partial<Record<keyof FormData, string>>>({})
const submitted = ref(false)

const roles = [
  { label: 'Admin', value: 'admin' },
  { label: 'User', value: 'user' },
  { label: 'Guest', value: 'guest' }
]

const isValid = computed(() => {
  return Object.keys(errors.value).length === 0
})

function validate(): boolean {
  errors.value = {}

  if (!formData.value.email) {
    errors.value.email = 'Email is required'
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.value.email)) {
    errors.value.email = 'Invalid email format'
  }

  if (!formData.value.password) {
    errors.value.password = 'Password is required'
  } else if (formData.value.password.length < 8) {
    errors.value.password = 'Password must be at least 8 characters'
  }

  if (!formData.value.role) {
    errors.value.role = 'Role is required'
  }

  return Object.keys(errors.value).length === 0
}

function handleSubmit() {
  submitted.value = true
  if (validate()) {
    // Submit form
    console.log('Form submitted:', formData.value)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="space-y-4">
    <div class="flex flex-col gap-2">
      <label for="email" class="font-medium">Email</label>
      <InputText
        id="email"
        v-model="formData.email"
        :class="{ 'p-invalid': errors.email }"
        aria-describedby="email-error"
      />
      <Message v-if="errors.email" severity="error" :closable="false">
        {{ errors.email }}
      </Message>
    </div>

    <div class="flex flex-col gap-2">
      <label for="password" class="font-medium">Password</label>
      <Password
        id="password"
        v-model="formData.password"
        :class="{ 'p-invalid': errors.password }"
        toggleMask
        :feedback="false"
        aria-describedby="password-error"
      />
      <Message v-if="errors.password" severity="error" :closable="false">
        {{ errors.password }}
      </Message>
    </div>

    <div class="flex flex-col gap-2">
      <label for="role" class="font-medium">Role</label>
      <Dropdown
        id="role"
        v-model="formData.role"
        :options="roles"
        optionLabel="label"
        optionValue="value"
        placeholder="Select a role"
        :class="{ 'p-invalid': errors.role }"
        aria-describedby="role-error"
      />
      <Message v-if="errors.role" severity="error" :closable="false">
        {{ errors.role }}
      </Message>
    </div>

    <Button type="submit" label="Submit" class="w-full" />
  </form>
</template>

Dialog & Overlay Patterns

Correct: Confirmation dialog with composable

// composables/useConfirmDialog.ts
import { useConfirm } from 'primevue/useconfirm'

export function useConfirmDialog() {
  const confirm = useConfirm()

  function confirmDelete(
    message: string,
    onAccept: () => void,
    onReject?: () => void
  ) {
    confirm.require({
      message,
      header: 'Confirm Delete',
      icon: 'pi pi-exclamation-triangle',
      rejectClass: 'p-button-secondary p-button-outlined',
      acceptClass: 'p-button-danger',
      rejectLabel: 'Cancel',
      acceptLabel: 'Delete',
      accept: onAccept,
      reject: onReject
    })
  }

  function confirmAction(options: {
    message: string
    header: string
    onAccept: () => void
    onReject?: () => void
  }) {
    confirm.require({
      message: options.message,
      header: options.header,
      icon: 'pi pi-info-circle',
      rejectClass: 'p-button-secondary p-button-outlined',
      acceptClass: 'p-button-primary',
      accept: options.onAccept,
      reject: options.onReject
    })
  }

  return {
    confirmDelete,
    confirmAction
  }
}

Usage:

<script setup lang="ts">
import { useConfirmDialog } from '@/composables/useConfirmDialog'
import ConfirmDialog from 'primevue/confirmdialog'

const { confirmDelete } = useConfirmDialog()

function handleDelete(item: Item) {
  confirmDelete(
    `Are you sure you want to delete "${item.name}"?`,
    () => deleteItem(item.id)
  )
}
</script>

<template>
  <ConfirmDialog />
  <Button label="Delete" severity="danger" @click="handleDelete(item)" />
</template>

Toast Notifications

Correct: Toast service with composable

// composables/useNotifications.ts
import { useToast } from 'primevue/usetoast'

export function useNotifications() {
  const toast = useToast()

  function success(summary: string, detail?: string) {
    toast.add({
      severity: 'success',
      summary,
      detail,
      life: 3000
    })
  }

  function error(summary: string, detail?: string) {
    toast.add({
      severity: 'error',
      summary,
      detail,
      life: 5000
    })
  }

  function warn(summary: string, detail?: string) {
    toast.add({
      severity: 'warn',
      summary,
      detail,
      life: 4000
    })
  }

  function info(summary: string, detail?: string) {
    toast.add({
      severity: 'info',
      summary,
      detail,
      life: 3000
    })
  }

  return { success, error, warn, info }
}

Accessibility Best Practices

PrimeVue components are WCAG 2.0 compliant. Ensure proper usage:

Correct: Accessible form fields

<template>
  <div class="flex flex-col gap-2">
    <label :for="id" class="font-medium">
      {{ label }}
      <span v-if="required" class="text-red-500" aria-hidden="true">*</span>
    </label>
    <InputText
      :id="id"
      v-model="modelValue"
      :aria-required="required"
      :aria-invalid="!!error"
      :aria-describedby="error ? `${id}-error` : undefined"
    />
    <small
      v-if="error"
      :id="`${id}-error`"
      class="text-red-500"
      role="alert"
    >
      {{ error }}
    </small>
  </div>
</template>

Lazy Loading Components

Correct: Async component loading for large PrimeVue components

// components/lazy/index.ts
import { defineAsyncComponent } from 'vue'

export const LazyDataTable = defineAsyncComponent({
  loader: () => import('primevue/datatable'),
  loadingComponent: () => import('@/components/ui/TableSkeleton.vue'),
  delay: 200
})

export const LazyEditor = defineAsyncComponent({
  loader: () => import('primevue/editor'),
  loadingComponent: () => import('@/components/ui/EditorSkeleton.vue'),
  delay: 200
})

export const LazyChart = defineAsyncComponent({
  loader: () => import('primevue/chart'),
  loadingComponent: () => import('@/components/ui/ChartSkeleton.vue'),
  delay: 200
})

Anti-Patterns to Avoid

Don't Mutate Props

Incorrect:

<script setup>
const props = defineProps(['items'])

function addItem(item) {
  props.items.push(item)  // Never mutate props!
}
</script>

Correct:

<script setup>
const props = defineProps(['items'])
const emit = defineEmits(['update:items'])

function addItem(item) {
  emit('update:items', [...props.items, item])
}
</script>

Don't Use v-if with v-for

Incorrect:

<template>
  <div v-for="item in items" v-if="item.isActive" :key="item.id">
    {{ item.name }}
  </div>
</template>

Correct:

<script setup>
const activeItems = computed(() => items.value.filter(item => item.isActive))
</script>

<template>
  <div v-for="item in activeItems" :key="item.id">
    {{ item.name }}
  </div>
</template>

Don't Store Derived State

Incorrect:

<script setup>
const items = ref([])
const itemCount = ref(0)  // Derived state stored separately

watch(items, () => {
  itemCount.value = items.value.length  // Manually syncing
})
</script>

Correct:

<script setup>
const items = ref([])
const itemCount = computed(() => items.value.length)  // Computed property
</script>

Don't Destructure Reactive Objects

Incorrect:

<script setup>
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state  // Loses reactivity!
</script>

Correct:

<script setup>
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state)  // Preserves reactivity
</script>

Don't Concatenate Tailwind Class Names

Dynamic class concatenation breaks Tailwind's compiler and classes get purged in production:

Incorrect:

<script setup>
const color = ref('blue')
</script>

<template>
  <!-- Classes will be purged in production! -->
  <div :class="`bg-${color}-500 text-${color}-900`">
    Content
  </div>
</template>

Correct:

<script setup>
const color = ref<'blue' | 'green' | 'red'>('blue')

const colorClasses = computed(() => {
  const colors = {
    blue: 'bg-blue-500 text-blue-900',
    green: 'bg-green-500 text-green-900',
    red: 'bg-red-500 text-red-900'
  }
  return colors[color.value]
})
</script>

<template>
  <div :class="colorClasses">
    Content
  </div>
</template>

Don't Overuse @apply

Excessive @apply usage defeats the purpose of utility-first CSS:

Incorrect:

/* styles.css */
.card {
  @apply mx-auto max-w-md rounded-xl bg-white p-6 shadow-lg;
}

.card-title {
  @apply text-xl font-semibold text-gray-900;
}

.card-description {
  @apply mt-2 text-gray-600;
}

.card-button {
  @apply mt-4 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700;
}

Correct: Use Vue components instead

<!-- components/Card.vue -->
<template>
  <div class="mx-auto max-w-md rounded-xl bg-white p-6 shadow-lg">
    <h2 class="text-xl font-semibold text-gray-900">
      <slot name="title" />
    </h2>
    <p class="mt-2 text-gray-600">
      <slot name="description" />
    </p>
    <div class="mt-4">
      <slot name="actions" />
    </div>
  </div>
</template>

Don't Use Conflicting Utilities

Applying multiple utilities that target the same CSS property causes unpredictable results:

Incorrect:

<template>
  <!-- Both flex and grid target display property -->
  <div class="flex grid">Content</div>

  <!-- Multiple margin utilities conflict -->
  <div class="m-4 mx-6">Content</div>
</template>

Correct:

<template>
  <div :class="isGrid ? 'grid' : 'flex'">Content</div>

  <!-- Use specific margin utilities -->
  <div class="mx-6 my-4">Content</div>
</template>

Don't Ignore Accessibility

Always include proper accessibility attributes alongside visual styling:

Incorrect:

<template>
  <button class="rounded bg-blue-600 p-2 text-white">
    <IconX />
  </button>
</template>

Correct:

<template>
  <button
    class="rounded bg-blue-600 p-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
    aria-label="Close dialog"
  >
    <IconX aria-hidden="true" />
  </button>
</template>

Don't Create Overly Long Class Strings

Break down complex class combinations into logical groups or components:

Incorrect:

<template>
  <div class="mx-auto mt-8 flex max-w-4xl flex-col items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white p-6 shadow-lg transition-all duration-300 hover:border-blue-500 hover:shadow-xl dark:border-gray-700 dark:bg-gray-800 sm:flex-row sm:gap-6 md:p-8 lg:gap-8">
    <!-- 15+ utilities on one element -->
  </div>
</template>

Correct: Extract to component or use computed

<script setup>
const containerClasses = [
  // Layout
  'mx-auto max-w-4xl flex flex-col sm:flex-row',
  'items-center justify-between',
  'gap-4 sm:gap-6 lg:gap-8',
  // Spacing
  'mt-8 p-6 md:p-8',
  // Visual
  'rounded-xl border bg-white shadow-lg',
  'border-gray-200 dark:border-gray-700 dark:bg-gray-800',
  // Interactive
  'transition-all duration-300',
  'hover:border-blue-500 hover:shadow-xl'
]
</script>

<template>
  <div :class="containerClasses">
    <slot />
  </div>
</template>

Don't Override PrimeVue Styles with CSS

Using CSS overrides bypasses the design system and causes maintenance issues:

Incorrect:

/* styles.css - Avoid this approach */
.p-button {
  background-color: #3b82f6 !important;
  border-radius: 8px !important;
}

.p-datatable .p-datatable-thead > tr > th {
  background: #f3f4f6 !important;
}

Correct: Use design tokens or PassThrough

// main.ts - Use design tokens
app.use(PrimeVue, {
  theme: {
    preset: Aura,
    options: {
      cssLayer: {
        name: 'primevue',
        order: 'tailwind-base, primevue, tailwind-utilities'
      }
    }
  },
  pt: {
    button: {
      root: { class: 'rounded-lg' }
    }
  }
})

Don't Import Entire PrimeVue Library

Importing everything bloats bundle size:

Incorrect:

// main.ts - Don't do this
import PrimeVue from 'primevue/config'
import * as PrimeVueComponents from 'primevue'  // Imports everything!

Object.entries(PrimeVueComponents).forEach(([name, component]) => {
  app.component(name, component)
})

Correct: Import only what you need

// main.ts - Tree-shakeable imports
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'

app.component('Button', Button)
app.component('DataTable', DataTable)
app.component('Column', Column)

Don't Mix Styled and Unstyled Inconsistently

Mixing modes creates visual inconsistency:

Incorrect:

// main.ts
app.use(PrimeVue, {
  unstyled: true  // Global unstyled
})

// SomeComponent.vue - Using styled component anyway
<Button label="Click" />  // No styles applied, looks broken

Correct: Choose one approach consistently

// Option 1: Styled mode with PT customization
app.use(PrimeVue, {
  theme: { preset: Aura },
  pt: { /* global customizations */ }
})

// Option 2: Unstyled mode with complete PT styling
app.use(PrimeVue, {
  unstyled: true,
  pt: {
    button: {
      root: { class: 'px-4 py-2 bg-primary-600 text-white rounded-lg' }
    }
    // ... complete styling for all components
  }
})

Don't Ignore Accessibility Attributes

PrimeVue provides accessibility out of the box, don't disable or ignore it:

Incorrect:

<template>
  <!-- Missing aria attributes and label -->
  <Button icon="pi pi-trash" @click="deleteItem" />

  <!-- No error message association -->
  <InputText v-model="email" :class="{ 'p-invalid': hasError }" />
  <span class="error">Invalid email</span>
</template>

Correct: Maintain accessibility

<template>
  <Button
    icon="pi pi-trash"
    aria-label="Delete item"
    @click="deleteItem"
  />

  <div class="flex flex-col gap-2">
    <label for="email">Email</label>
    <InputText
      id="email"
      v-model="email"
      :class="{ 'p-invalid': hasError }"
      :aria-invalid="hasError"
      aria-describedby="email-error"
    />
    <small id="email-error" v-if="hasError" class="text-red-500" role="alert">
      Invalid email
    </small>
  </div>
</template>

Don't Hardcode PassThrough in Every Component

Repeating PT configuration across components creates duplication:

Incorrect:

<!-- ComponentA.vue -->
<Button :pt="{ root: { class: 'rounded-lg shadow-md' } }" />

<!-- ComponentB.vue -->
<Button :pt="{ root: { class: 'rounded-lg shadow-md' } }" />

<!-- ComponentC.vue -->
<Button :pt="{ root: { class: 'rounded-lg shadow-md' } }" />

Correct: Use global PT or wrapper components

// main.ts - Global configuration
app.use(PrimeVue, {
  pt: {
    button: {
      root: { class: 'rounded-lg shadow-md' }
    }
  }
})

// Or use wrapper components (see Wrapper Components Pattern above)

Nuxt.js Specific Guidelines

When using Nuxt.js, follow these additional patterns:

  • Auto-imports: Leverage Nuxt's auto-imports for Vue APIs and composables
  • useFetch/useAsyncData: Use Nuxt's data fetching composables for SSR-compatible data loading
  • definePageMeta: Use for page-level metadata and middleware
  • Server routes: Use server/api/ for API endpoints
  • Runtime config: Use useRuntimeConfig() for environment variables

References

Vue.js

Tailwind CSS

PrimeVue