| name | vue3-ux-design |
| version | 1.0.0 |
| stack | Vue 3.x + Vite + Pinia |
| description | Use when building Vue 3 components, creating dashboards, implementing forms, optimizing data-heavy interfaces, or making designs responsive. Mandates design tokens, accessibility, proper state management, error states, and template cleanliness for maintainable UX. |
<EXTREMELY_IMPORTANT>
You MUST read the entire skill. Every section exists because agents failed without it.
Skipping sections = guaranteed failure. This skill is 900+ lines because each line prevents a real mistake.
</EXTREMELY_IMPORTANT>
Vue 3 UX Design - Production Quality Standards
When This Skill Applies
MANDATORY for:
- Building any Vue 3 component
- Creating real-time dashboards or data visualizations
- Implementing forms with validation
- Rendering large datasets (tables, lists)
- Making interfaces mobile-responsive
- Any UI work in Vue 3 + Vite + Pinia stack
Not optional. If you're writing Vue 3 code, you use this skill.
Critical Rule: Time Pressure is NOT Permission
<EXTREMELY_IMPORTANT>
If you catch yourself thinking ANY of these thoughts, STOP. You are rationalizing away quality.
❌ "Time pressure means skip error handling"
❌ "This is simple enough for inline styles"
❌ "I'm tired, here's the quick fix"
❌ "Users expect simple interface" (as excuse to skip semantic HTML)
❌ "Performance is critical" (as excuse for premature optimization)
❌ "This is just a prototype"
Users don't care about your timeline or fatigue. They need working, accessible software.
</EXTREMELY_IMPORTANT>
Rationalization Counter Table
Before skipping ANY step, check this table:
| If You're Thinking... | Why You're Wrong | What You Must Do |
|---|
| "Time pressure means skip error handling" | Users hit errors immediately. No error handling = broken software | Add try/catch and loading states FIRST, before any features |
| "This is simple enough for inline styles" | Every "simple" component becomes complex. Refactoring later is 10x harder | Extract to computed properties and design tokens from the start |
| "I'm tired, here's the quick fix" | Technical debt is permanent. Your "quick fix" ships to production | Take a break OR follow the process. No shortcuts. |
| "Users expect simple interface" | Simple UX requires complex implementation. "Simple" ≠ skip accessibility | Semantic HTML + ARIA are non-negotiable regardless of "simplicity" |
| "Performance is critical so I'll optimize now" | Premature optimization creates unmaintainable code | Profile first with Vue DevTools, optimize second with data |
| "Grid CSS is straightforward" | CSS complexity is invisible until debugging at 2am | Use design tokens and extract magic numbers before writing CSS |
| "This is just a prototype" | Prototypes become production. Always. | Build it right the first time or don't build it |
| "Store makes this simpler than props" | Tight coupling prevents reuse and testing | Props + callbacks first. Store only for truly global state. |
If a rationalization isn't in this table, it's still wrong. Check yourself.
Phase 1: Design Tokens BEFORE Any Code
DO THIS FIRST. Not optional. Not later. NOW.
1.1 Create Design Token File
Before writing ANY component code, create or update your design tokens file:
File: src/styles/tokens.js
export const tokens = {
colors: {
backgroundPrimary: '#1e1e2e',
backgroundSecondary: '#2a2a3e',
backgroundTertiary: '#3a3a4e',
textPrimary: '#e0e0e0',
textSecondary: '#a0a0b0',
textTertiary: '#808090',
interactive: '#10b981',
interactiveHover: '#34d399',
interactiveFocus: '#6ee7b7',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#06b6d4',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
},
}
1.2 Import Tokens in Components
<script setup>
import { tokens } from '@/styles/tokens'
// Use tokens for any dynamic styling
const cardStyle = computed(() => ({
backgroundColor: tokens.colors.backgroundSecondary,
padding: tokens.spacing.lg,
borderRadius: tokens.borderRadius.md,
}))
</script>
1.3 Use CSS Custom Properties
File: src/styles/variables.css
:root {
--color-bg-primary: #1e1e2e;
--color-bg-secondary: #2a2a3e;
--color-text-primary: #e0e0e0;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
--border-radius-md: 8px;
--transition-base: 0.3s ease;
}
Why: Magic numbers scattered in code are unmaintainable. One source of truth.
Failure Mode: Skipping this because "it's extra work upfront" → leads to inconsistent spacing, colors everywhere, impossible to theme.
Phase 2: Accessibility Checklist (NON-NEGOTIABLE)
Every component MUST pass this checklist. No exceptions.
2.1 Semantic HTML First
❌ NEVER:
<div class="button" @click="submit">Submit</div>
<div class="input-wrapper">
<div class="label">Name</div>
<div class="input" @input="..."></div>
</div>
✅ ALWAYS:
<button type="submit" @click="submit">Submit</button>
<form @submit.prevent="handleSubmit">
<label for="name">Name</label>
<input id="name" type="text" v-model="name" />
</form>
Semantic elements: <button>, <form>, <label>, <input>, <select>, <nav>, <main>, <article>, <section>, <header>, <footer>, <aside>
Why: Screen readers rely on semantic HTML. Div soup is inaccessible.
2.2 ARIA Attributes
Interactive elements:
<!-- Buttons with icons only -->
<button aria-label="Close dialog" @click="close">
<XIcon />
</button>
<!-- Toggle buttons -->
<button
:aria-pressed="isActive"
@click="toggle"
>
Toggle Feature
</button>
<!-- Form validation -->
<input
id="email"
type="email"
v-model="email"
:aria-invalid="hasError"
:aria-describedby="hasError ? 'email-error' : undefined"
/>
<span v-if="hasError" id="email-error" role="alert">
{{ errorMessage }}
</span>
Data tables:
<table>
<thead>
<tr>
<th scope="col" role="columnheader" :aria-sort="sortState">
Episode
</th>
</tr>
</thead>
</table>
Live regions (for real-time updates):
<div aria-live="polite" aria-atomic="true">
Episode {{ currentEpisode }} completed: {{ reward }} reward
</div>
2.3 Keyboard Navigation
All interactive elements MUST be keyboard accessible:
<div
role="button"
tabindex="0"
@click="handleClick"
@keydown.enter="handleClick"
@keydown.space.prevent="handleClick"
>
Custom Button
</div>
Focus management:
const firstInputRef = ref(null)
onMounted(() => {
firstInputRef.value?.focus()
})
2.4 Touch Targets
Minimum 44x44px for all interactive elements (WCAG guideline):
.button {
min-height: 44px;
min-width: 44px;
padding: var(--spacing-sm) var(--spacing-md);
}
2.5 Color Contrast
WCAG AA minimum: 4.5:1 for normal text, 3:1 for large text
color: { bg: '#2a2a3e', text: '#4a4a5e' }
color: { bg: '#2a2a3e', text: '#e0e0e0' }
Check contrast: Use browser DevTools or https://webaim.org/resources/contrastchecker/
2.6 Test With Screen Readers
Before marking component complete:
If you skip this, your component is incomplete.
Phase 3: State Management Patterns
3.1 Decision Tree: Pinia vs Props vs Composables
Is the state needed by 3+ unrelated components?
├─ Yes → Consider Pinia store
└─ No ↓
Is the state specific to this component tree?
├─ Yes → Use props + events
└─ No ↓
Is the state reusable logic with reactivity?
├─ Yes → Create composable
└─ No → Local ref/reactive
3.2 Props First (Decouple from Store)
❌ NEVER (tight coupling):
<script setup>
import { useSimulationStore } from '@/stores/simulation'
const store = useSimulationStore()
const data = computed(() => store.episodeHistory)
</script>
✅ ALWAYS (loose coupling):
<script setup>
// Component accepts data via props
defineProps({
episodes: {
type: Array,
required: true
}
})
</script>
<!-- Parent passes data from store -->
<EpisodeTable :episodes="store.episodeHistory" />
Why: Component can be reused with different data sources, tested with mock data, moved to different projects.
3.3 When to Use Pinia
✅ Good use cases:
- Authentication state (current user, token)
- WebSocket connection status
- Application-wide settings (theme, locale)
- Large shared datasets (cached API responses)
❌ Bad use cases:
- Component-local state (use
ref)
- Parent-child communication (use props/events)
- Form state (use local state + validation library)
- Derived data (use computed props)
3.4 Composables for Reusable Logic
export function useWebSocket(url) {
const isConnected = ref(false)
const error = ref(null)
const data = ref(null)
const connect = () => {
}
const disconnect = () => {
}
onUnmounted(() => {
disconnect()
})
return {
isConnected,
error,
data,
connect,
disconnect,
}
}
Use in component:
<script setup>
import { useWebSocket } from '@/composables/useWebSocket'
const { isConnected, data, connect } = useWebSocket('ws://localhost:8765')
</script>
Phase 4: Error/Loading/Empty States (The 3-State Pattern)
EVERY data operation needs 3 states. No exceptions.
4.1 The Template Pattern
<template>
<div class="data-container">
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<SpinnerIcon />
<p>Loading episodes...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state" role="alert">
<AlertIcon />
<p>{{ error.message }}</p>
<button @click="retry">Retry</button>
</div>
<!-- Empty State -->
<div v-else-if="!data || data.length === 0" class="empty-state">
<InboxIcon />
<p>No episodes yet. Start training to see results.</p>
</div>
<!-- Data State -->
<div v-else class="data-state">
<!-- Render actual data -->
</div>
</div>
</template>
4.2 The Script Pattern
const isLoading = ref(false)
const error = ref(null)
const data = ref(null)
async function fetchData() {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/episodes')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
} catch (err) {
error.value = err
console.error('Failed to fetch episodes:', err)
} finally {
isLoading.value = false
}
}
function retry() {
fetchData()
}
4.3 Store Data Pattern
If using Pinia store, expose loading/error states:
export const useSimulationStore = defineStore('simulation', () => {
const episodes = ref([])
const isLoading = ref(false)
const error = ref(null)
async function fetchEpisodes() {
isLoading.value = true
error.value = null
try {
} catch (err) {
error.value = err
} finally {
isLoading.value = false
}
}
return {
episodes,
isLoading,
error,
fetchEpisodes,
}
})
Why: Users need feedback. Silent failures are broken experiences.
Failure Mode: "I'll add error handling later" → it never happens → production breaks silently.
Phase 5: Template Cleanliness
5.1 No Business Logic in Templates
❌ NEVER:
<template>
<div :style="{ width: (epsilon * 100) + '%' }">
<div :class="percentage < 20 ? 'critical' : percentage < 60 ? 'warning' : 'good'">
<div v-for="item in data.filter(x => x.reward > 10).sort((a,b) => b.reward - a.reward)">
</template>
✅ ALWAYS:
<template>
<div :style="{ width: epsilonPercentage }">
<div :class="statusClass">
<div v-for="item in filteredSortedData" :key="item.id">
</template>
<script setup>
// Move calculations to computed
const epsilonPercentage = computed(() => `${epsilon.value * 100}%`)
const statusClass = computed(() => {
if (percentage.value < 20) return 'critical'
if (percentage.value < 60) return 'warning'
return 'good'
})
const filteredSortedData = computed(() => {
return props.data
.filter(item => item.reward > REWARD_THRESHOLD)
.sort((a, b) => b.reward - a.reward)
})
</script>
5.2 Extract Constants
❌ NEVER:
if (percentage < 20) return 'critical'
if (stress > 80) showAlert()
fetchData({ limit: 100 })
✅ ALWAYS:
const THRESHOLD_CRITICAL = 20
const THRESHOLD_HIGH_STRESS = 80
const DEFAULT_PAGE_SIZE = 100
if (percentage < THRESHOLD_CRITICAL) return 'critical'
if (stress > THRESHOLD_HIGH_STRESS) showAlert()
fetchData({ limit: DEFAULT_PAGE_SIZE })
5.3 Extract Complex Computed
If computed has >5 lines of logic, extract to utility function:
export function calculateMetricStatus(value, thresholds) {
if (value < thresholds.critical) return 'critical'
if (value < thresholds.warning) return 'warning'
return 'good'
}
import { calculateMetricStatus } from '@/utils/dataTransforms'
const statusClass = computed(() =>
calculateMetricStatus(percentage.value, METRIC_THRESHOLDS)
)
Phase 6: Responsive Design Standards
6.1 Mobile-First CSS
❌ NEVER (desktop-first):
.container {
width: 1200px;
padding: 2rem;
}
@media (max-width: 768px) {
.container {
width: 100%;
padding: 1rem;
}
}
✅ ALWAYS (mobile-first):
.container {
width: 100%;
padding: 1rem;
}
@media (min-width: 768px) {
.container {
width: 1200px;
padding: 2rem;
}
}
6.2 Standard Breakpoints
Use consistent breakpoints (from tokens.js):
@media (min-width: 640px) {
}
@media (min-width: 768px) {
}
@media (min-width: 1024px) {
}
@media (min-width: 1280px) {
}
6.3 Touch-Friendly Controls
All interactive elements 44x44px minimum:
.button, .input, .select {
min-height: 44px;
min-width: 44px;
}
6.4 Responsive Typography
.heading {
font-size: 1.25rem;
}
@media (min-width: 768px) {
.heading {
font-size: 1.5rem;
}
}
6.5 Container Queries (Modern Approach)
For component-level responsiveness:
.card-container {
container-type: inline-size;
}
.card {
padding: 1rem;
}
@container (min-width: 400px) {
.card {
padding: 2rem;
}
}
6.6 Test Responsive Behavior
Before marking complete:
Phase 7: Performance Patterns
7.1 When to Optimize
❌ DO NOT optimize until you have:
- Measured performance with Vue DevTools
- Identified actual bottleneck
- Confirmed it's a user-facing issue
✅ DO optimize when:
- Rendering >1000 items in list
- Running heavy computation on every reactive change
- Loading large datasets without pagination
7.2 Virtual Scrolling (Large Lists Only)
Only use if list has 1000+ items:
<!-- Install: npm install vue-virtual-scroller -->
<template>
<RecycleScroller
:items="episodes"
:item-size="36"
key-field="id"
v-slot="{ item }"
>
<div class="episode-row">
{{ item.episode }}: {{ item.reward }}
</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>
7.3 Debounce User Input
For search/filter inputs:
import { useDebounceFn } from '@vueuse/core'
const searchQuery = ref('')
const debouncedSearch = useDebounceFn(() => {
performSearch(searchQuery.value)
}, 300)
watch(searchQuery, debouncedSearch)
7.4 Lazy Load Heavy Components
<script setup>
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
)
</script>
<template>
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
7.5 Optimize Computed Dependencies
const filteredData = computed(() =>
store.allData.filter(x => x.type === props.filter)
)
const filteredData = computed(() => {
const data = store.allData
const filter = props.filter
return data.filter(x => x.type === filter)
})
Phase 8: Component Structure Standards
8.1 File Organization
src/
├── components/
│ ├── base/ # Reusable primitives (Button, Input, Card)
│ ├── features/ # Feature-specific (Dashboard, EpisodeTable)
│ └── layouts/ # Layout components (AppLayout, GridLayout)
├── composables/ # Reusable reactive logic
├── stores/ # Pinia stores
├── utils/ # Pure functions (no reactivity)
├── styles/
│ ├── tokens.js # Design tokens
│ ├── variables.css # CSS custom properties
│ └── global.css # Global styles
└── views/ # Route pages
8.2 Component Template
<template>
<!-- Template here -->
</template>
<script setup>
// 1. Imports
import { ref, computed, watch, onMounted } from 'vue'
import { useStore } from '@/stores/myStore'
import { tokens } from '@/styles/tokens'
// 2. Props & Emits
const props = defineProps({
data: {
type: Array,
required: true
}
})
const emit = defineEmits(['update', 'delete'])
// 3. Stores
const store = useStore()
// 4. Local state
const isLoading = ref(false)
const error = ref(null)
// 5. Computed
const processedData = computed(() => {
return props.data.map(transform)
})
// 6. Methods
function handleClick() {
emit('update', processedData.value)
}
// 7. Lifecycle
onMounted(() => {
// Initialization
})
// 8. Watchers
watch(() => props.data, (newData) => {
// React to prop changes
})
</script>
<style scoped>
/* Component styles */
/* Use tokens, not magic numbers */
</style>
Phase 9: Testing & Verification
9.1 Manual Testing Checklist
Before marking component complete:
Functionality:
Accessibility:
Responsive:
Performance:
9.2 Code Review Checklist
Before submitting:
Summary: The Golden Path
For every Vue 3 component:
- Design Tokens → Create/update tokens.js with colors, spacing, typography
- Accessibility → Semantic HTML, ARIA attributes, keyboard navigation
- Props First → Accept data via props, emit events, minimize store coupling
- 3-State Pattern → Loading, error, empty states for all data operations
- Clean Templates → Business logic in computed/methods, not templates
- Mobile-First → Responsive CSS with standard breakpoints, 44px touch targets
- Measure First → Profile before optimizing, use virtual scroll only when needed
- Test Everything → Manual testing checklist before marking complete
If you skip any of these steps, your component is incomplete.
Time pressure, exhaustion, or "this is simple" are NOT valid reasons to skip steps.
Enforcement
How to use this skill:
- Announce you're using the vue3-ux-design skill
- Reference design tokens FIRST (before any component code)
- As you write each component, explicitly check against:
- Accessibility checklist
- 3-state pattern
- Template cleanliness rules
- Responsive standards
- Before marking component complete, run through testing checklist
If you catch yourself rationalizing away any step, STOP and re-read the Rationalization Counter Table.
This skill is mandatory for all Vue 3 work. Not optional.
Long Conversation Reminder
If this is a long conversation and you're approaching context limits:
- STOP and re-read this skill before continuing
- Verify you're still following ALL phases (not just some)
- Check the Rationalization Counter Table again
- Review the testing checklist
Context loss = quality loss. If you can't remember the full skill, re-read it. Don't continue from memory.