Skip to content
DasPeTe Tutorials

Vue With Vite, Tailwind CSS and PrimeVue

Set up a Vue 3 project with TypeScript and Vite by hand, file by file, then add Tailwind CSS and PrimeVue and make them share their colors and dark mode.

Quest objectives

  • Set up a Vue 3 project with Vite and TypeScript by hand
  • Type-check .vue files with vue-tsc
  • Add Tailwind CSS 4 through its Vite plugin
  • Add PrimeVue with a theme in your own color
  • Make Tailwind and PrimeVue share colors, CSS layers and dark mode

A project generator sets up Vue in one command, but leaves you with a dozen files you didn't choose. Built by hand, the project has only what it needs, and you know what every file is for. This tutorial builds a Vue 3 project with TypeScript and Vite step by step, adds Tailwind CSS and then the component library PrimeVue, and makes the two work together.

You need Node.js 20.19, 22.12 or newer, which is what Vite 8 runs on. The commands use npm. With pnpm or Yarn, write pnpm add or yarn add where it says npm install.

The project

A folder and a package.json are all a project needs to begin with:

Terminal
$ mkdir my-app$ cd my-app
package.json
{
  "name": "my-app",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --build && vite build",
    "preview": "vite preview"
  }
}

"type": "module" makes Node treat the project's JavaScript as ES modules. The scripts are the three you will use: dev starts the development server, build type-checks the project and then builds it for production, and preview serves that build.

Now the packages. Vue is needed at runtime, everything else only while you develop:

Terminal
$ npm install vue$ npm install -D vite @vitejs/plugin-vue typescript@~6.0 vue-tsc @vue/tsconfig @tsconfig/node24 @types/node@24
  • vite is the development server and build tool, and @vitejs/plugin-vue teaches it single-file components.

  • vue-tsc type-checks .vue files, which plain tsc can't read.

  • @vue/tsconfig and @tsconfig/node24 are ready-made TypeScript settings for the browser and for Node, and @types/node describes Node's APIs for vite.config.ts.

TypeScript

The project has code for two environments: the app runs in the browser, and vite.config.ts runs in Node. Each gets its own configuration, and a third file ties them together:

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.node.json" },
    { "path": "./tsconfig.app.json" }
  ]
}
tsconfig.app.json
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["src/**/*.ts", "src/**/*.vue"],
  "compilerOptions": {
    "types": ["vite/client"],
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
  }
}
tsconfig.node.json
{
  "extends": "@tsconfig/node24/tsconfig.json",
  "include": ["vite.config.ts"],
  "compilerOptions": {
    "module": "preserve",
    "moduleResolution": "bundler",
    "types": ["node"],
    "noEmit": true,
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
  }
}

vue-tsc --build follows the references in tsconfig.json and checks both parts. For the app:

  • @vue/tsconfig brings the strict settings Vue recommends, including strict itself.

  • vite/client adds Vite's types, so TypeScript knows import.meta.env and accepts imports of CSS and images.

  • noUncheckedIndexedAccess treats list[0] as possibly undefined, which it is for an empty list.

  • noUnusedLocals and noUnusedParameters point out code that does nothing.

The .tsbuildinfo files remember what was checked, so the next check only looks at what changed. They go into node_modules/.tmp, out of the way.

Vite and the entry point

Vite needs one plugin for now:

vite.config.ts
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [vue()],
});

The page itself is index.html in the project's root. Vite starts from it and follows the script tag to src/main.ts:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My app</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
src/main.ts
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');
src/App.vue
<script setup lang="ts">
const name = 'app';
</script>

<template>
  <h1>Hello {{ name }}</h1>
</template>

Start the development server:

Terminal
$ npm run dev  VITE v8.3.5  ready in 312 ms  ➜  Local:   http://localhost:5173/  ➜  Network: use --host to expose  ➜  press h + enter to show help

Open http://localhost:5173/, and "Hello app" is there. Leave the server running: from now on, every change shows up in the browser as soon as you save.

Tailwind CSS

Tailwind 4 comes as a Vite plugin and needs neither a tailwind.config.js nor PostCSS:

Terminal
$ npm install -D tailwindcss @tailwindcss/vite
vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [vue(), tailwindcss()],
});

A stylesheet imports Tailwind. Its settings, such as your own colors, go into the same file with @theme when you need them:

src/assets/main.css
@import 'tailwindcss';

main.ts imports the stylesheet before anything else:

src/main.ts
import './assets/main.css';

import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

To see that it works, give the heading a few classes:

src/App.vue
<script setup lang="ts">
const name = 'app';
</script>

<template>
  <h1 class="p-8 text-3xl font-bold text-orange-500">Hello {{ name }}</h1>
</template>

Vite notices the changed configuration and restarts on its own. The heading turns big, bold and orange.

PrimeVue

PrimeVue brings ready-made components: buttons, tables, dialogs, date pickers and about 80 more, with themes you can adapt.

Terminal
$ npm install primevue@4 @primeuix/themes@2$ npm install -D tailwindcss-primeui

@primeuix/themes holds PrimeVue's themes, and tailwindcss-primeui makes PrimeVue's colors available as Tailwind classes. It goes into the stylesheet, together with a dark variant that matches PrimeVue's:

src/assets/main.css
@import 'tailwindcss';
@import 'tailwindcss-primeui';

/* dark: applies inside an element with the class dark, the same selector PrimeVue uses */
@custom-variant dark (&:where(.dark, .dark *));

Tailwind's dark: classes normally follow the system setting. With the custom variant, they apply inside an element with the class dark instead, so a switch on the page can turn dark mode on and off.

PrimeVue's own setup goes into a file of its own, so main.ts stays short:

src/primevue.ts
import { definePreset } from '@primeuix/themes';
import Aura from '@primeuix/themes/aura';
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import type { App } from 'vue';

// Aura, with orange instead of emerald as the primary color
const Preset = definePreset(Aura, {
  semantic: {
    primary: {
      50: '{orange.50}',
      100: '{orange.100}',
      200: '{orange.200}',
      300: '{orange.300}',
      400: '{orange.400}',
      500: '{orange.500}',
      600: '{orange.600}',
      700: '{orange.700}',
      800: '{orange.800}',
      900: '{orange.900}',
      950: '{orange.950}',
    },
  },
});

export function installPrimeVue(app: App) {
  app.use(PrimeVue, {
    ripple: true,
    theme: {
      preset: Preset,
      options: {
        darkModeSelector: '.dark',
        // Puts PrimeVue's styles between Tailwind's base and utilities,
        // so a utility class on a component wins
        cssLayer: {
          name: 'primevue',
          order: 'theme, base, primevue',
        },
      },
    },
  });

  // For toast messages, through useToast()
  app.use(ToastService);
}
  • definePreset takes the Aura theme and replaces its primary color. {orange.500} refers to a color of the theme's palette, so all eleven shades fit together.

  • darkModeSelector: '.dark' is the same selector as the custom variant in the stylesheet: one class on <html> switches both.

  • cssLayer puts PrimeVue's styles into a CSS layer of its own, between Tailwind's base styles and its utilities. A Tailwind class on a PrimeVue component therefore wins, without !important.

  • ripple turns on the ripple effect when a button is clicked, and the ToastService lets any component show toast messages.

main.ts calls it before mounting the app:

src/main.ts
import './assets/main.css';

import { createApp } from 'vue';
import App from './App.vue';
import { installPrimeVue } from './primevue';

const app = createApp(App);

installPrimeVue(app);

app.mount('#app');

And App.vue tries it all out: two PrimeVue buttons, a toast and a dark mode switch, with Tailwind classes in between:

src/App.vue
<script setup lang="ts">
import Button from 'primevue/button';
import Toast from 'primevue/toast';
import { useToast } from 'primevue/usetoast';

const toast = useToast();

function greet() {
  toast.add({ severity: 'success', summary: 'Hello', detail: 'PrimeVue works.', life: 3000 });
}

function toggleDarkMode() {
  document.documentElement.classList.toggle('dark');
}
</script>

<template>
  <Toast />
  <main class="min-h-screen bg-surface-0 p-8 text-surface-900 dark:bg-surface-950 dark:text-surface-0">
    <h1 class="text-3xl font-bold text-primary">Hello app</h1>
    <p class="mt-2 text-muted-color">Tailwind and PrimeVue share their colors and dark mode.</p>
    <div class="mt-6 flex gap-2">
      <Button label="Say hello" @click="greet" />
      <Button label="Dark mode" severity="secondary" class="px-6" @click="toggleDarkMode" />
    </div>
  </main>
</template>

Components are imported one by one from their own path, such as primevue/button, so the build only contains the ones you use. In the browser:

  • The heading and the first button are orange. text-primary, text-muted-color and the surface colors come from tailwindcss-primeui and are the theme's own colors.

  • The second button is wider than the first: Tailwind's px-6 beats PrimeVue's own padding, thanks to the layer order.

  • Say hello shows a toast in the top right corner.

  • Dark mode puts the class dark on <html>. The page turns dark through Tailwind's dark: classes, and PrimeVue's buttons switch to their dark colors at the same time.

Building for production

Terminal
$ npm run build$ npm run preview

npm run build type-checks the project and writes the finished site to dist, and npm run preview serves it at http://localhost:4173/ to try. The project now looks like this, next to node_modules:

text
my-app/
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
└── src/
    ├── App.vue
    ├── main.ts
    ├── primevue.ts
    └── assets/
        └── main.css

With PrimeVue 5

With a license key for PrimeVue 5, install primevue@5 and @primeuix/themes@3 instead and add the key to the options in src/primevue.ts. Everything else stays as it is:

src/primevue.ts
app.use(PrimeVue, {
  license: 'your license key',
  theme: {
    // ...as above
  },
});

From here, Your First Vue 3 Composables is a good next step.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.