Vue is a JavaScript framework that allows developers to build user interfaces. It was created by Evan You as a personal side project in 2014, and it is now an independent, community-driven project. Vue is highly flexible and can be adopted gradually by projects.
We will create a song playlist with Vue. Users can favorite songs and search by title.
- Vue 3
- Vite
- Vue Router
- TypeScript
- Tailwind CSS
- Heroicons
- ESLint
- Prettier
- Node 16+
- VSCode
- ESLint extension
- Prettier extension
- Vue Language Features (Volar) extension
- TypeScript Vue Plugin (Volar) extension
// settings.json
{
// ...
"editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"prettier.trailingComma": "none",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
The first step is to install Vue.
npm create vue@latest
Answer yes to everything except JSX, Pinia, Vitest, and end-to-end testing.
✔ Project name: … <your-project-name>
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit testing? … No / Yes
✔ Add an End-to-End Testing Solution? … No / Cypress / Playwright
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes
Then, install the dependencies.
npm install
Update the .eslintrc.cjs
file to use plugin:vue/vue3-strongly-recommended
. See eslint-plugin-vue
and the official Vue style guide.
// .eslintrc.cjs
// ...
module.exports = {
// ...
extends: [
"plugin:vue/vue3-strongly-recommended"
],
};
Update the .prettierrc.json
file.
// .prettierrc.json
{
// ...
"semi": true,
"singleQuote": false
}
Re-format all of the files.
npm run format
Start the dev server.
npm run dev
public/
- contains public files that likely won’t change (e.g.favicon.ico
)src/
- the source folderassets/
- contains the assets the build tool will process. It usually contains these types of files: stylesheets, fonts, and imagescomponents/
- where you put all of your Vue components that can be imported into other componentsrouter/
- contains the router instance and all of the routes for the appviews/
- Vue components that handle routesApp.vue
- the root Vue componentmain.ts
- the file that initializes the Vue app
.eslintrc.cjs
- the ESLint config file.gitignore
- specifies which files and folders should not be committed to Git.prettierrc.json
- the Prettier config file.env.d.ts
- Vite’s client typesindex.html
- the file where the single-page application (SPA) is mountedpackage.json
- contains project metadata such as name, version, and dependenciesREADME.md
- markdown file that is shown in the GitHub repo descriptiontsconfig.app.json
- the TypeScript config filestsconfig.json
tsconfig.node.json
vite.config.ts
- the Vite config file
Delete these files:
- assets/base.css
- assets/logo.svg
- assets/main.css
- components/HelloWorld.vue
- components/icons/IconCommunity.vue
- components/icons/IconDocumentation.vue
- components/icons/IconEcosystem.vue
- components/icons/IconSupport.vue
- components/icons/IconTooling.vue
- components/TheWelcome.vue
- components/WelcomeItem.vue
- views/AboutView.vue
Update App.vue
. router-view
will display the component that corresponds to the URL.
// App.vue
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
Remove import "./assets/style.css";
from main.ts
.
Remove the “about” route from router/index.ts
.
Remove TheWelcome
component from views/HomeView.vue
.
Install Tailwind CSS.
npm install --save-dev tailwindcss postcss autoprefixer
npx tailwindcss init -p
Add the paths to your template files.
// tailwind.config.js
module.exports = {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
// ...
};
Add a style.css
file under the assets folder, and add the tailwind directives.
// assets/style.css
@tailwind base;
@tailwind components;
@tailwind utilities;
Import style.css
into main.ts
import "./assets/style.css";
Add some code to test if Tailwind is working.
// views/HomeView.vue
<template>
<main>
<h1 class="text-3xl font-bold underline">Hello world!</h1>
</main>
</template>
Start the dev server.
npm run dev
// assets/style.css
@import url("https://fonts.googleapis.com/css2?family=Heebo:wght@100;200;300;400;500;600;700;800;900&display=swap");
//...
@layer base {
html {
font-family: "Heebo", sans-serif;
}
}
Install Heroicons
npm install @heroicons/vue
Add a global background color to style.css
// assets/style.css
// ...
@layer base {
html {
font-family: "Heebo", sans-serif;
@apply bg-neutral-900;
}
}
Create the markup for the header
// views/HomeView.vue
<template>
<header class="py-8 border-b-[1px] border-neutral-700">
<div class="max-w-5xl mx-auto md:flex justify-between items-center px-4 md:px-8">
<div class="flex items-center gap-6 mb-6 md:mb-0">
<!-- Play button -->
<button
aria-label="Play playlist"
class="bg-green-600 border-2 border-green-600 rounded-full w-12 h-12 flex items-center justify-center transition-all hover:scale-105 focus:outline-none focus:ring-2 focus:border-neutral-900 ring-green-800"
>
<PlayIcon class="h-6 w-6 text-neutral-900" />
</button>
<!-- Title -->
<h1 class="text-3xl md:text-4xl font-bold text-white">Good Vibes</h1>
</div>
<!-- Search-->
<div class="relative w-full md:w-auto">
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon class="h-6 w-6 text-white" />
</div>
<input
type="text"
placeholder="Search by song title..."
class="bg-neutral-700 border-2 border-neutral-700 rounded-md text-white py-2 pl-12 pr-4 placeholder:text-neutral-300 focus:outline-none focus:ring-2 focus:border-neutral-900 ring-green-800 w-full md:w-auto"
/>
</div>
</div>
</header>
<main></main>
</template>
<script setup lang="ts">
import { PlayIcon } from "@heroicons/vue/24/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
</script>
// api/playlist.json
[
{
"id": 1,
"title": "Classical Lines",
"artists": ["Careless Angel"],
"duration": "1:04",
"album": {
"name": "Hyperspace",
"image": "https://picsum.photos/id/10/300/300"
}
},
{
"id": 2,
"title": "Sweet Saturday",
"artists": ["Heartsick Images"],
"duration": "3:52",
"album": {
"name": "Feel The Reflections",
"image": "https://picsum.photos/id/11/300/300"
}
},
{
"id": 3,
"title": "Fortunate Memory",
"artists": ["Happy Flow", "Friday Crash"],
"duration": "3:03",
"album": {
"name": "A Distilled Flow",
"image": "https://picsum.photos/id/12/300/300"
}
},
{
"id": 4,
"title": "Believe In A Thing Called Lethargy",
"artists": ["Big Juice"],
"duration": "4:19",
"album": {
"name": "Crying World",
"image": "https://picsum.photos/id/13/300/300"
}
},
{
"id": 5,
"title": "I Lose My Ways",
"artists": ["Money Voice"],
"duration": "3:54",
"album": {
"name": "Your Soul",
"image": "https://picsum.photos/id/14/300/300"
}
},
{
"id": 6,
"title": "Not Enough Beauty",
"artists": ["Young Jazz", "Pained Solo"],
"duration": "3:37",
"album": {
"name": "Is This Spring",
"image": "https://picsum.photos/id/15/300/300"
}
},
{
"id": 7,
"title": "Garden Of Voice",
"artists": ["Soft Concerto"],
"duration": "5:03",
"album": {
"name": "So Long End",
"image": "https://picsum.photos/id/16/300/300"
}
},
{
"id": 8,
"title": "Fresh Balance",
"artists": ["Another Vision"],
"duration": "3:39",
"album": {
"name": "Sunny Nature",
"image": "https://picsum.photos/id/17/300/300"
}
},
{
"id": 9,
"title": "Stuck With Shadows",
"artists": ["Good Dreams"],
"duration": "4:53",
"album": {
"name": "Uplifting Day",
"image": "https://picsum.photos/id/18/300/300"
}
},
{
"id": 10,
"title": "Unexpected Promises",
"artists": ["Rest Adventure"],
"duration": "5:42",
"album": {
"name": "Space For Echo",
"image": "https://picsum.photos/id/19/300/300"
}
}
]
// tsconfig.app.json
{
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.json"],
// ...
}
Add a file to export our types. The isFavorite
property is optional.
// types/index.ts
export interface Album {
name: string;
image: string;
}
export interface Song {
id: number;
title: string;
artists: string[];
duration: string;
album: Album;
isFavorite?: boolean;
}
// components/PlaylistTable.vue
<template>
<div class="max-w-5xl mx-auto px-4 md:px-8 py-8 overflow-x-auto">
<table class="w-full min-w-max text-neutral-300 text-sm border-collapse">
<thead>
<tr>
<!-- Header -->
<th
v-for="header in headers"
:key="header"
class="text-left font-normal border-b-[1px] border-neutral-700 py-2 px-4"
>
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(song, index) in songs" :key="song.id">
<!-- Number -->
<td :class="['py-2 px-4', { 'pt-6': index === 0 }]">{{ index + 1 }}</td>
<!-- Title -->
<td :class="['flex gap-4 py-2 px-4', { 'pt-6': index === 0 }]">
<img :src="song.album.image" :alt="song.album.name" class="w-10 h-10" />
<div>
<p class="text-white text-base">{{ song.title }}</p>
<p>{{ getArtists(song.artists) }}</p>
</div>
</td>
<!-- Album -->
<td :class="['py-2 px-4', { 'pt-6': index === 0 }]">{{ song.album.name }}</td>
<!-- Duration -->
<td :class="['py-2 px-4', { 'pt-6': index === 0 }]">{{ song.duration }}</td>
<!-- Favorite -->
<td :class="['py-2 px-4 text-center', { 'pt-6': index === 0 }]">
<button aria-label="Favorite" class="focus:outline-none">
<HeartIcon class="h-6 w-6 text-neutral-300 hover:text-white" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { HeartIcon } from "@heroicons/vue/24/outline";
import type { Song } from "@/types";
defineProps<{
songs: Song[];
}>();
const headers = ref(["#", "Title", "Album", "Duration", ""]);
const getArtists = (artists: Array<string>) => artists.join(", ");
</script>
Add the PlaylistTable
to HomeView
// views/HomeView.vue
<template>
<!-- ... -->
<main>
<PlaylistTable :songs="songs" />
</main>
</template>
<script setup lang="ts">
import { ref } from "vue";
import PlaylistTable from "@/components/PlaylistTable.vue";
import { PlayIcon } from "@heroicons/vue/24/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import playlist from "@/api/playlist.json";
import type { Song } from "@/types";
const songs = ref<Song[]>(playlist);
</script>
The most basic form of data binding is text interpolation using the “mustache” syntax (double curly braces). The mustache tag will be replaced with the value. It will also update whenever that value changes. You can use JavaScript expressions inside all data bindings.
Mustaches cannot be used inside HTML attributes. You have to use the v-bind directive. The shorthand syntax for v-bind
is :
.
In Composition API, the recommended way to declare reactive state is using ref()
. ref()
takes the argument and returns it wrapped within a ref object with a .value
property. You don’t need to append .value
when using a ref in the template. Refs are automatically unwrapped when used inside templates.
You can use the v-for
directive to render a list of items based on an array. Inside of the v-for
scope, template expressions have access to all parent scope properties. It is recommended to provide a key
attribute with v-for
whenever possible. This helps Vue reuse and reorder existing elements.
Props are custom attributes you can use to pass data from a parent component to a child component. When the parent property updates, it will flow down to the child, but not the other way around. You should not attempt to mutate a prop inside a child component.
Import the solid heart icon. This will indicate that the song has been favorited.
// components/PlaylistTable.vue
<script setup lang="ts">
// ...
import { HeartIcon as HeartIconOutline } from "@heroicons/vue/24/outline";
import { HeartIcon as HeartIconSolid } from "@heroicons/vue/24/solid";
</script>
Add another button and icon for the solid heart icon.
// components/PlaylistTable.vue
// ...
<!-- Favorite -->
<td :class="['py-2 px-4 text-center', { 'pt-6': index === 0 }]">
<button aria-label="Favorite" class="focus:outline-none">
<HeartIconOutline class="h-6 w-6 text-neutral-300 hover:text-white" />
</button>
<button aria-label="Unfavorite" class="focus:outline-none">
<HeartIconSolid class="h-6 w-6 text-green-600 hover:text-green-700" />
</button>
</td>
Create a function that will emit an event to the parent component.
// components/PlaylistTable.vue
<script setup lang="ts">
// ...
const emit = defineEmits(["addToFavorites"]);
const addToFavorites = (id: number) => {
emit("addToFavorites", id);
};
</script>
Add a click event listener to the button.
// components/PlaylistTable.vue
<button aria-label="Favorite" class="focus:outline-none" @click="addToFavorites(song.id)">
<HeartIconOutline class="h-6 w-6 text-neutral-300 hover:text-white" />
</button>
Add another function to emit an event to the parent component.
// components/PlaylistTable.vue
<script setup lang="ts">
// ...
const emit = defineEmits(["addToFavorites", "removeFromFavorites"]);
const removeFromFavorites = (id: number) => {
emit("removeFromFavorites", id);
};
</script>
Add a click listener to the button.
// components/PlaylistTable.vue
<button
aria-label="Unfavorite"
class="focus:outline-none"
@click="removeFromFavorites(song.id)"
>
<HeartIconSolid class="h-6 w-6 text-green-600 hover:text-green-700" />
</button>
Add conditional rendering to the buttons. The favorite button should only appear if the song has not been favorited yet and vice versa.
// components/PlaylistTable.vue
<button
v-if="!song.isFavorite"
aria-label="Favorite"
class="focus:outline-none"
@click="addToFavorites(song.id)"
>
<HeartIconOutline class="h-6 w-6 text-neutral-300 hover:text-white" />
</button>
<button
v-else
aria-label="Unfavorite"
class="focus:outline-none"
@click="removeFromFavorites(song.id)"
>
<HeartIconSolid class="h-6 w-6 text-green-600 hover:text-green-700" />
</button>
Capture the emitted events and update the songs.
// views/HomeView.vue
<template>
<!-- ... -->
<main>
<PlaylistTable
:songs="songs"
@add-to-favorites="addToFavorites"
@remove-from-favorites="removeFromFavorites"
/>
</main>
</template>
<script setup lang="ts">
// ...
const addToFavorites = (id: number) => {
const index = songs.value.findIndex((song) => song.id === id);
songs.value[index].isFavorite = true;
};
const removeFromFavorites = (id: number) => {
const index = songs.value.findIndex((song) => song.id === id);
songs.value[index].isFavorite = false;
};
</script>
The directive v-if
is used to conditionally render a block. The block will only be rendered if the directive’s expression returns a truthy value. You can use the v-else
directive to indicate an “else block.”
We can use the v-on
directive (shorthand @
symbol) to listen for DOM events and run some code when triggered.
A component can emit custom events. You can pass arguments along with the emitted event. The parent component can listen for the event using v-on
or the @
symbol shorthand.
Add a ref()
and v-model
for the search input
// views/HomeView.vue
<template>
<header class="py-8 border-b-[1px] border-neutral-700">
<div class="max-w-5xl mx-auto md:flex justify-between items-center px-4 md:px-8">
<!-- ... -->
<!-- Search-->
<div class="relative w-full md:w-auto">
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon class="h-6 w-6 text-white" />
</div>
<input
v-model="searchTerm"
type="text"
placeholder="Search by song title..."
class="bg-neutral-700 border-2 border-neutral-700 rounded-md text-white py-2 pl-12 pr-4 placeholder:text-neutral-300 focus:outline-none focus:ring-2 focus:border-neutral-900 ring-green-800 w-full md:w-auto"
/>
</div>
</div>
</header>
<main>
<PlaylistTable
:songs="filteredSongs"
@add-to-favorites="addToFavorites"
@remove-from-favorites="removeFromFavorites"
/>
</main>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
// ...
const songs = ref<Song[]>(playlist);
const searchTerm = ref("");
const filteredSongs = computed(() =>
songs.value.filter((song) => song.title.toLowerCase().includes(searchTerm.value.toLowerCase()))
);
// ...
</script>
We can use v-model
to sync the state of form input elements with the state in JavaScript. The v-model
is a simplified version of this:
<input
:value="text"
@input="event => text = event.target.value">
You can use computed properties for complex logic that includes reactive data. A computed property automatically tracks its reactive dependencies. Vue will update any bindings that depend on the computed property when the reactive data changes.