On this page
Final project: Contact Manager with Vue 3.5, Pinia, and TypeScript
Final project — Contact Manager
Congratulations on reaching the final lesson! You have covered the full breadth of Vue 3.5 — from the reactivity system and the Composition API to routing, global state, forms, animations, and testing. Now it is time to bring everything together in a cohesive, production-quality application.
In this playground lesson you will build a Contact Manager — a single-page application that demonstrates every major concept from the course. No external dependencies beyond Vue itself; everything runs in the browser.
What you will build
The Contact Manager has four primary features:
- Contact list — Display contacts alphabetically, filter by category, and search by name or email.
- Contact detail panel — Click a contact to view full details in a slide-in panel.
- Create / edit modal — A validated form with all input types from the forms lesson.
- Favorites and categories — Toggle favorites, color-code contacts by category, and display aggregate stats.
Features and Vue concepts demonstrated
| Feature | Vue concepts |
|---|---|
| Reactive contact list | ref<Contact[]>, computed() for filtering/sorting |
| Search and filter | ref + computed + v-model |
| Add/edit modal | reactive() for form draft, v-if, v-model, @submit.prevent |
| Delete with animation | TransitionGroup, filter() |
| Slide-in detail panel | Transition, :key for re-animation |
| Category avatars | :style dynamic binding, computed initials |
| Favorites toggle | Direct mutation on reactive array item |
| Stat counters | Chained computed() |
| Keyboard/a11y | aria-label, role="dialog", aria-modal |
Architecture overview
Everything is contained in a single ContactManager.vue for playground simplicity. In a real project, you would split this into:
src/
stores/
contacts.ts ← Pinia store (state + actions)
composables/
useContactSearch.ts ← Search/filter logic
useContactForm.ts ← Form state + validation
components/
ContactList.vue
ContactCard.vue
ContactDetail.vue
ContactForm.vue
views/
ContactsView.vue ← OrchestratorKey implementation decisions
reactive() for the form draft
The form draft uses reactive() instead of individual ref() fields. This makes Object.assign(draft, newValues) work naturally for resetting the form between operations:
const draft = reactive<ContactDraft>(blankDraft())
// Reset when opening a new contact form
Object.assign(draft, blankDraft())
// Pre-populate when editing
Object.assign(draft, {
firstName: contact.firstName,
// ...
})computed() for filtered + sorted list
The filtered list uses a chain of computed() operations:
const filtered = computed(() => {
let list = contacts.value
// Apply category filter first (cheaper)
if (filterCat.value !== 'all') {
list = list.filter(c => c.category === filterCat.value)
}
// Then apply search (string operation)
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(c =>
`${c.firstName} ${c.lastName} ${c.email}`.toLowerCase().includes(q)
)
}
// Always sort alphabetically by last name
return list.sort((a, b) =>
`${a.lastName}${a.firstName}`.localeCompare(`${b.lastName}${b.firstName}`)
)
})Since computed() is cached, the filtering only runs when contacts, filterCat, or search actually change.
TransitionGroup for the contact list
The contact list is wrapped in <TransitionGroup name="card" tag="ul">. Every contact <li> has :key="contact.id" — this is required for Vue to track insertions, deletions, and moves:
/* Items entering the list */
.card-enter-from { opacity: 0; transform: translateY(-8px); }
/* Items leaving the list */
.card-leave-to { opacity: 0; transform: translateY(8px); }
/* Items shifting position (FLIP animation) */
.card-move { transition: transform 0.3s ease; }Modal accessibility
The modal uses ARIA attributes to be accessible to screen readers:
<div
v-if="showForm"
role="dialog"
aria-modal="true"
aria-label="Contact form"
class="modal-overlay"
>For a fully accessible modal in production, add: focus trap (move focus inside modal on open, return focus to trigger on close), Escape key to close, and prevent background scroll.
Suggested enhancements
Once you are comfortable with the base implementation, try these extensions:
1. Persistence — Extract a useLocalStorage composable and save/restore the contacts array.
2. Pinia store — Move all state and actions to useContactStore. Notice how the component becomes a thin template layer.
3. Import/export — Add buttons to export contacts as CSV and import from a JSON file using the File API.
4. Drag to reorder — Use the @vueuse/core useSortable composable to enable drag-and-drop reordering within a category.
5. Tests — Write Vitest + Vue Test Utils tests for: adding a contact, deleting a contact, search filtering, and the form validation.
6. Vue Router — Add routes /contacts, /contacts/:id, and /contacts/new so the detail panel and form have their own shareable URLs.
Congratulations!
You have completed the Vue.js Essentials course. Along the way you have learned:
- The Vue 3.5 Composition API —
ref,reactive,computed,watch,watchEffect - Single File Components —
<script setup lang="ts">, scoped styles, compiler macros - Template syntax — all directives, control flow, dynamic bindings
- Component communication —
defineProps,defineEmits,defineModel, provide/inject - Composables — reusable stateful logic, lifecycle hooks, MaybeRefOrGetter
- Vue Router 4 — routes, guards, lazy loading, programmatic navigation
- Pinia — stores, getters, actions, persistence, cross-store communication
- Forms — v-model deep dive, modifiers, validation patterns, accessibility
- Fetching and Suspense — async setup, watchEffect, error boundaries
- Transitions — Transition, TransitionGroup, FLIP animations, JS hooks
- Testing — Vitest, Vue Test Utils, Testing Library, Pinia testing
This knowledge forms a solid foundation for building professional Vue 3 applications. Explore the Vue Ecosystem path to go further with Nuxt, VueUse, TailwindCSS integration, and advanced patterns.
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
// ─── Types ───────────────────────────────────────────────
interface Contact {
id: number
firstName: string
lastName: string
email: string
phone: string
category: 'work' | 'personal' | 'family'
favorite: boolean
createdAt: string
}
type ContactDraft = Omit<Contact, 'id' | 'createdAt' | 'favorite'>
// ─── State ───────────────────────────────────────────────
const contacts = ref<Contact[]>([
{
id: 1,
firstName: 'Ada',
lastName: 'Lovelace',
email: '[email protected]',
phone: '+1 555-0101',
category: 'work',
favorite: true,
createdAt: '2026-01-15',
},
{
id: 2,
firstName: 'Grace',
lastName: 'Hopper',
email: '[email protected]',
phone: '+1 555-0202',
category: 'work',
favorite: false,
createdAt: '2026-02-10',
},
{
id: 3,
firstName: 'Alan',
lastName: 'Turing',
email: '[email protected]',
phone: '+44 555-0303',
category: 'personal',
favorite: true,
createdAt: '2026-03-05',
},
])
const search = ref('')
const filterCat = ref<Contact['category'] | 'all'>('all')
const showForm = ref(false)
const editingId = ref<number | null>(null)
const selectedId = ref<number | null>(null)
let nextId = 4
const blankDraft = (): ContactDraft => ({
firstName: '',
lastName: '',
email: '',
phone: '',
category: 'personal',
})
const draft = reactive<ContactDraft>(blankDraft())
// ─── Derived ─────────────────────────────────────────────
const filtered = computed(() => {
let list = contacts.value
if (filterCat.value !== 'all') {
list = list.filter(c => c.category === filterCat.value)
}
if (search.value.trim()) {
const q = search.value.trim().toLowerCase()
list = list.filter(c =>
`${c.firstName} ${c.lastName} ${c.email}`.toLowerCase().includes(q)
)
}
return list.sort((a, b) =>
`${a.lastName}${a.firstName}`.localeCompare(`${b.lastName}${b.firstName}`)
)
})
const selected = computed(() =>
contacts.value.find(c => c.id === selectedId.value) ?? null
)
const favoriteCount = computed(() =>
contacts.value.filter(c => c.favorite).length
)
// ─── Actions ─────────────────────────────────────────────
function openNew() {
editingId.value = null
Object.assign(draft, blankDraft())
showForm.value = true
}
function openEdit(contact: Contact) {
editingId.value = contact.id
Object.assign(draft, {
firstName: contact.firstName,
lastName: contact.lastName,
email: contact.email,
phone: contact.phone,
category: contact.category,
})
showForm.value = true
}
function saveContact() {
if (!draft.firstName.trim() || !draft.email.trim()) return
if (editingId.value !== null) {
const idx = contacts.value.findIndex(c => c.id === editingId.value)
if (idx !== -1) Object.assign(contacts.value[idx], draft)
} else {
contacts.value.push({
id: nextId++,
...draft,
favorite: false,
createdAt: new Date().toISOString().slice(0, 10),
})
}
showForm.value = false
}
function deleteContact(id: number) {
contacts.value = contacts.value.filter(c => c.id !== id)
if (selectedId.value === id) selectedId.value = null
}
function toggleFavorite(id: number) {
const c = contacts.value.find(c => c.id === id)
if (c) c.favorite = !c.favorite
}
function selectContact(id: number) {
selectedId.value = selectedId.value === id ? null : id
}
// ─── Helpers ─────────────────────────────────────────────
function initials(c: Contact) {
return `${c.firstName[0]}${c.lastName[0]}`.toUpperCase()
}
const categoryColors: Record<Contact['category'], string> = {
work: '#3b82f6',
personal: '#8b5cf6',
family: '#10b981',
}
</script>
<template>
<div class="app">
<!-- ── Header ────────────────────────────── -->
<header class="header">
<h1 class="logo">Contacts</h1>
<div class="stats">
<span>{{ contacts.length }} total</span>
<span>{{ favoriteCount }} ★ favorites</span>
</div>
<button type="button" class="btn-primary" @click="openNew">+ New contact</button>
</header>
<!-- ── Toolbar ───────────────────────────── -->
<div class="toolbar">
<input
v-model="search"
type="search"
class="search"
placeholder="Search by name or email…"
aria-label="Search contacts"
/>
<select v-model="filterCat" aria-label="Filter by category" class="filter">
<option value="all">All categories</option>
<option value="work">Work</option>
<option value="personal">Personal</option>
<option value="family">Family</option>
</select>
</div>
<!-- ── Main layout ───────────────────────── -->
<div class="layout">
<!-- Contact list -->
<section class="list" aria-label="Contact list">
<p v-if="filtered.length === 0" class="empty">No contacts found.</p>
<TransitionGroup name="card" tag="ul">
<li
v-for="contact in filtered"
:key="contact.id"
class="card"
:class="{ selected: selectedId === contact.id }"
@click="selectContact(contact.id)"
>
<div
class="avatar"
:style="{ background: categoryColors[contact.category] }"
aria-hidden="true"
>{{ initials(contact) }}</div>
<div class="info">
<strong>{{ contact.firstName }} {{ contact.lastName }}</strong>
<span class="email">{{ contact.email }}</span>
</div>
<div class="actions">
<button
type="button"
:aria-label="contact.favorite ? 'Remove from favorites' : 'Add to favorites'"
:class="{ fav: contact.favorite }"
@click.stop="toggleFavorite(contact.id)"
>★</button>
<button
type="button"
aria-label="Edit contact"
@click.stop="openEdit(contact)"
>✎</button>
<button
type="button"
aria-label="Delete contact"
class="danger"
@click.stop="deleteContact(contact.id)"
>✕</button>
</div>
</li>
</TransitionGroup>
</section>
<!-- Detail panel -->
<Transition name="slide">
<section v-if="selected" class="detail" :key="selected.id">
<div
class="detail-avatar"
:style="{ background: categoryColors[selected.category] }"
aria-hidden="true"
>{{ initials(selected) }}</div>
<h2>{{ selected.firstName }} {{ selected.lastName }}</h2>
<span class="badge" :style="{ background: categoryColors[selected.category] }">
{{ selected.category }}
</span>
<dl class="detail-data">
<dt>Email</dt>
<dd><a :href="`mailto:${selected.email}`">{{ selected.email }}</a></dd>
<dt>Phone</dt>
<dd>{{ selected.phone || '—' }}</dd>
<dt>Added</dt>
<dd>{{ selected.createdAt }}</dd>
<dt>Favorite</dt>
<dd>{{ selected.favorite ? 'Yes ★' : 'No' }}</dd>
</dl>
<div class="detail-actions">
<button type="button" class="btn-primary" @click="openEdit(selected)">Edit</button>
<button type="button" class="btn-danger" @click="deleteContact(selected.id)">Delete</button>
</div>
</section>
</Transition>
</div>
<!-- ── Modal form ─────────────────────────── -->
<Transition name="fade">
<div v-if="showForm" class="modal-overlay" role="dialog" aria-modal="true" aria-label="Contact form">
<div class="modal">
<h2>{{ editingId !== null ? 'Edit contact' : 'New contact' }}</h2>
<form @submit.prevent="saveContact">
<div class="row">
<label>
First name *
<input v-model.trim="draft.firstName" type="text" required autocomplete="given-name" />
</label>
<label>
Last name
<input v-model.trim="draft.lastName" type="text" autocomplete="family-name" />
</label>
</div>
<label>
Email *
<input v-model.trim="draft.email" type="email" required autocomplete="email" />
</label>
<label>
Phone
<input v-model.trim="draft.phone" type="tel" autocomplete="tel" />
</label>
<fieldset>
<legend>Category</legend>
<label><input type="radio" v-model="draft.category" value="work" /> Work</label>
<label><input type="radio" v-model="draft.category" value="personal" /> Personal</label>
<label><input type="radio" v-model="draft.category" value="family" /> Family</label>
</fieldset>
<div class="modal-footer">
<button type="button" @click="showForm = false">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* Layout */
.app { font-family: system-ui, sans-serif; min-height: 100vh; background: #f8fafc; color: #1e293b; }
.header { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.5rem; background: white; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; }
.logo { font-size: 1.5rem; font-weight: 700; flex: 1; }
.stats { display: flex; gap: 1rem; font-size: 0.875rem; color: #64748b; }
.toolbar { display: flex; gap: 0.75rem; padding: 1rem 1.5rem; background: white; border-bottom: 1px solid #e2e8f0; }
.search, .filter { padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 0.375rem; font-size: 0.875rem; }
.search { flex: 1; }
.layout { display: grid; grid-template-columns: 1fr; gap: 0; }
@media (min-width: 640px) { .layout { grid-template-columns: 340px 1fr; } }
/* Card list */
.list { overflow-y: auto; max-height: calc(100vh - 9rem); }
ul { list-style: none; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
.card { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem; cursor: pointer; transition: border-color 0.15s; }
.card:hover { border-color: #94a3b8; }
.card.selected { border-color: #3b82f6; background: #eff6ff; }
.avatar { width: 2.5rem; height: 2.5rem; border-radius: 50%; display: grid; place-items: center; color: white; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; }
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.email { font-size: 0.75rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; gap: 0.25rem; }
.actions button { background: none; border: 1px solid transparent; border-radius: 0.25rem; padding: 0.25rem 0.375rem; cursor: pointer; font-size: 0.875rem; color: #64748b; transition: all 0.15s; }
.actions button:hover { border-color: #cbd5e1; color: #1e293b; }
.actions button.fav { color: #f59e0b; }
.actions button.danger:hover { color: #ef4444; border-color: #fca5a5; }
.empty { padding: 2rem; text-align: center; color: #94a3b8; }
/* Detail */
.detail { padding: 2rem 1.5rem; background: white; border-left: 1px solid #e2e8f0; }
.detail-avatar { width: 5rem; height: 5rem; border-radius: 50%; display: grid; place-items: center; color: white; font-weight: 700; font-size: 1.5rem; margin-block-end: 1rem; }
.detail h2 { font-size: 1.5rem; margin-block-end: 0.5rem; }
.badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 9999px; color: white; font-size: 0.75rem; font-weight: 600; margin-block-end: 1.5rem; }
.detail-data { display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1rem; }
dt { font-weight: 600; color: #64748b; font-size: 0.875rem; }
dd { color: #1e293b; }
.detail-actions { display: flex; gap: 0.75rem; margin-block-start: 1.5rem; }
/* Buttons */
.btn-primary { padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: background 0.15s; }
.btn-primary:hover { background: #2563eb; }
.btn-danger { padding: 0.5rem 1rem; background: #ef4444; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: background 0.15s; }
.btn-danger:hover { background: #dc2626; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: grid; place-items: center; z-index: 100; padding: 1rem; }
.modal { background: white; border-radius: 0.75rem; padding: 2rem; width: 100%; max-width: 480px; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal h2 { margin-block-end: 1.5rem; }
label { display: flex; flex-direction: column; gap: 0.375rem; font-size: 0.875rem; font-weight: 500; margin-block-end: 1rem; }
input[type="text"], input[type="email"], input[type="tel"] { padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 0.375rem; font-size: 0.875rem; }
input:focus { outline: 2px solid #3b82f6; outline-offset: 2px; }
fieldset { border: 1px solid #e2e8f0; border-radius: 0.375rem; padding: 0.75rem; margin-block-end: 1rem; }
legend { font-weight: 600; font-size: 0.875rem; padding-inline: 0.25rem; }
fieldset label { flex-direction: row; align-items: center; gap: 0.5rem; margin: 0; font-weight: normal; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.modal-footer { display: flex; justify-content: flex-end; gap: 0.75rem; margin-block-start: 1.5rem; }
.modal-footer button:first-child { padding: 0.5rem 1rem; background: none; border: 1px solid #cbd5e1; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; }
/* Transitions */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-enter-active, .slide-leave-active { transition: all 0.3s ease; }
.slide-enter-from { opacity: 0; transform: translateX(20px); }
.slide-leave-to { opacity: 0; transform: translateX(-20px); }
.card-enter-active, .card-leave-active { transition: all 0.3s ease; }
.card-enter-from { opacity: 0; transform: translateY(-8px); }
.card-leave-to { opacity: 0; transform: translateY(8px); }
.card-move { transition: transform 0.3s ease; }
</style>
Sign in to track your progress