Skip to content
TutorialStyling & UI

Component libraries in 2026: Nuxt UI, PrimeVue, Vuetify and when to use none

The same customer page three times: in Nuxt UI 4.7, PrimeVue 4.5 and Vuetify 4.0. Then a fair comparison, a dialog built from Reka UI primitives, and a recommendation for each kind of project.

vuejs.school team14 May 20267 min readNuxt UI 4.7 · PrimeVue 4.5 · Vuetify 4.0

A component library gives you ready-made buttons, tables, dialogs and form fields, so you can build screens instead of rebuilding the same widgets. Picking one is a decision you live with for years. The three big Vue options (Nuxt UI, PrimeVue and Vuetify) make very different bets, and for some projects the right choice is no library at all.

The quickest way to see the differences is to build the same small page in each library: a table of customers, an "Add customer" button that opens a dialog, and a form inside it. The examples use a Nuxt 4.4 app, so pages live in app/pages/. The versions are the ones current on 14 May: @nuxt/ui 4.7.1, primevue 4.5.5 with @primeuix/themes, and vuetify 4.0.7.

Build the page with Nuxt UI 4

Nuxt UI is built on Reka UI, a set of unstyled, accessible Vue primitives, and on Tailwind CSS. Its introduction lists 125 or more components and says it works in plain Vue with Vite as well as in Nuxt. Since version 4 it is a single free, open-source package; the former Pro components are included.

To add it to a Nuxt app:

  1. Run npm install @nuxt/ui tailwindcss zod (Zod is for the form's schema).
  2. Add modules: ['@nuxt/ui'] to nuxt.config.ts.
  3. Create app/assets/css/main.css with @import "tailwindcss"; and @import "@nuxt/ui";, and load it with css: ['~/assets/css/main.css'] in nuxt.config.ts.
  4. Wrap your app in <UApp> in app/app.vue.

Then the page:

app/pages/customers.vue
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent, TableColumn } from '@nuxt/ui'

type Customer = { id: number, name: string, email: string }

const customers = ref<Customer[]>([
  { id: 1, name: 'Asha Patel', email: 'asha@example.com' },
  { id: 2, name: 'Tom Cook', email: 'tom@example.com' },
])
const columns: TableColumn<Customer>[] = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'email', header: 'Email' },
]

const schema = z.object({
  name: z.string().min(1, 'Enter a name'),
  email: z.email('Enter a valid email'),
})
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({ name: undefined, email: undefined })
const open = ref(false)

function onSubmit(event: FormSubmitEvent<Schema>) {
  customers.value.push({ id: Date.now(), ...event.data })
  open.value = false
}
</script>

<template>
  <UModal v-model:open="open" title="New customer">
    <UButton label="Add customer" />
    <template #body>
      <UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
        <UFormField label="Name" name="name">
          <UInput v-model="state.name" />
        </UFormField>
        <UFormField label="Email" name="email">
          <UInput v-model="state.email" type="email" />
        </UFormField>
        <UButton type="submit" label="Save" />
      </UForm>
    </template>
  </UModal>
  <UTable :data="customers" :columns="columns" />
</template>

The button in UModal's default slot becomes the dialog's trigger, so it needs no click handler, and the form goes in the #body slot. UTable is built on TanStack Table, so sorting, filtering, pagination and row selection come from the same column definitions.

UForm checks state against the schema, shows each error under the UFormField with the same name, and calls onSubmit only when the data is valid. It accepts any Standard Schema library (a shared interface that Zod, Valibot and others implement); z.email() is Zod 4's email check. Nothing is styled by hand: the look comes from Tailwind classes inside the components, which you override through props, the ui prop or your app config.

Build the page with PrimeVue 4

PrimeVue is made by PrimeTek, the company behind PrimeFaces, PrimeNG and PrimeReact, and it has no paywall; paid add-ons such as templates and a Figma kit are optional. It has two modes:

  • Styled mode ships themes built from design tokens (named values for colours, spacing and the like), with presets called Aura, Material, Lara and Nora.
  • Unstyled mode removes all CSS and leaves you the behaviour and accessibility, to style with Tailwind or anything else.

Its pass-through API lets you add classes and attributes to any element inside a component.

To add it to a Nuxt app:

  1. Run npm install primevue @primeuix/themes and npm install -D @primevue/nuxt-module.
  2. In nuxt.config.ts, add '@primevue/nuxt-module' to modules and set primevue: { options: { theme: { preset: Aura } } }, where Aura is imported from @primeuix/themes/aura.

The module auto-imports PrimeVue's components, so the page needs no imports:

app/pages/customers.vue
<script setup lang="ts">
type Customer = { id: number, name: string, email: string }

const customers = ref<Customer[]>([
  { id: 1, name: 'Asha Patel', email: 'asha@example.com' },
  { id: 2, name: 'Tom Cook', email: 'tom@example.com' },
])
const open = ref(false)
const form = reactive({ name: '', email: '' })

function save() {
  if (!form.name || !form.email.includes('@')) return
  customers.value.push({ id: Date.now(), ...form })
  Object.assign(form, { name: '', email: '' })
  open.value = false
}
</script>

<template>
  <Button label="Add customer" @click="open = true" />
  <Dialog v-model:visible="open" modal header="New customer">
    <form @submit.prevent="save">
      <label for="customer-name">Name</label>
      <InputText id="customer-name" v-model="form.name" />
      <label for="customer-email">Email</label>
      <InputText id="customer-email" v-model="form.email" type="email" />
      <Button type="submit" label="Save" />
    </form>
  </Dialog>
  <DataTable :value="customers">
    <Column field="name" header="Name" />
    <Column field="email" header="Email" />
  </DataTable>
</template>

Here the button opens the dialog with its own @click; the close button and Escape close it through v-model:visible. DataTable with Column children is PrimeVue's signature component, with sorting, filtering, row grouping, export and virtual scrolling built in.

The form is plain v-model, and save() silently refuses an empty name or an email without @. PrimeVue also has a separate @primevue/forms package for when you need validation state across fields, such as an error message under each one.

Build the page with Vuetify 4

Vuetify implements Google's Material Design. Version 4.0.0 was published on 23 February, and its release notes include these changes:

  • Typography and elevation (the shadow levels) follow Material Design 3.
  • The grid system is overhauled.
  • All styles sit in CSS cascade layers (@layer) instead of relying on !important.
  • The default theme is system, which follows the user's light or dark setting.
  • Buttons no longer force uppercase text.

In Nuxt, run npx nuxi@latest module add vuetify-nuxt-module; versions 0.19.3 and later of the module accept Vuetify 4. Then wrap your app in <v-app> in app/app.vue and write the page:

app/pages/customers.vue
<script setup lang="ts">
import type { DataTableHeader, SubmitEventPromise } from 'vuetify'

type Customer = { id: number, name: string, email: string }

const customers = ref<Customer[]>([
  { id: 1, name: 'Asha Patel', email: 'asha@example.com' },
  { id: 2, name: 'Tom Cook', email: 'tom@example.com' },
])
const headers: DataTableHeader[] = [
  { title: 'Name', key: 'name' },
  { title: 'Email', key: 'email' },
]
const open = ref(false)
const name = ref('')
const email = ref('')
const required = (value: string) => !!value || 'Required'

async function save(event: SubmitEventPromise) {
  const { valid } = await event
  if (!valid) return
  customers.value.push({ id: Date.now(), name: name.value, email: email.value })
  open.value = false
}
</script>

<template>
  <v-dialog v-model="open" max-width="420">
    <template #activator="{ props: activatorProps }">
      <v-btn v-bind="activatorProps" text="Add customer" />
    </template>
    <v-card title="New customer">
      <v-card-text>
        <v-form @submit.prevent="save">
          <v-text-field v-model="name" label="Name" :rules="[required]" />
          <v-text-field v-model="email" label="Email" type="email" :rules="[required]" />
          <v-btn type="submit" text="Save" />
        </v-form>
      </v-card-text>
    </v-card>
  </v-dialog>
  <v-data-table :headers="headers" :items="customers" />
</template>

Validation lives on the fields as rules, and the form's submit event doubles as a promise that resolves with { valid, errors }. The activator slot passes the ARIA attributes that connect the button to its dialog, which is why you bind activatorProps rather than adding a click handler.

Compare styling, accessibility, size, Nuxt integration and lock-in

Styling. Nuxt UI is Tailwind all the way down, so if your team already writes Tailwind it feels like your own code. PrimeVue lets you choose: a token-based theme you adjust, or unstyled components you dress yourself. Vuetify looks like Material Design unless you work hard against it; theme colours and Sass variables change the palette, not the personality.

Accessibility. All three take it seriously, in different words. Nuxt UI inherits Reka UI's implementation of the WAI-ARIA patterns: focus trapping, keyboard navigation, screen reader labels. PrimeVue states WCAG 2.1 AA compliance and documents keyboard and screen reader support on every component page. Vuetify's guide says its components provide keyboard interactions for all mouse actions and use semantic HTML where it applies. None of them can rescue an input without a label, so labels are still your job.

Size. All three tree-shake, so you ship only the components you use, and the Nuxt modules handle that through auto-imports. Their CSS differs: Nuxt UI emits Tailwind utilities, PrimeVue's styled mode builds its theme CSS from tokens, and Vuetify ships its own stylesheet. Published comparisons go stale quickly; build this page with each and compare the sizes nuxt build prints.

Nuxt integration. Nuxt UI is made by the Nuxt team and registers icons, fonts and colour mode for you. PrimeVue and Vuetify both have solid modules, maintained by their own teams and the community.

Lock-in. Every library leaks into every template. Leaving one means rewriting each page that uses it. Unstyled PrimeVue and Reka-based Nuxt UI keep your visual design in your own classes, which makes a later move a little cheaper.

Build your own with Reka UI, or use no library

The fourth option is no library at all, or rather, only the hard part of one. Reka UI 2.9 provides behaviour and accessibility with no styles: the dialog traps focus, closes on Escape, wires up the title for screen readers and makes the page behind it inert (clicks and screen readers cannot reach it). You add the Tailwind classes, here in a reusable dialog component:

app/components/AppDialog.vue
<script setup lang="ts">
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'

defineProps<{ title: string, description: string }>()
const open = defineModel<boolean>('open', { default: false })
</script>

<template>
  <DialogRoot v-model:open="open">
    <DialogTrigger as-child>
      <slot name="trigger" />
    </DialogTrigger>
    <DialogPortal>
      <DialogOverlay class="fixed inset-0 bg-black/40" />
      <DialogContent class="fixed top-1/2 left-1/2 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6">
        <DialogTitle class="text-lg font-semibold">{{ title }}</DialogTitle>
        <DialogDescription class="mt-1 text-sm text-gray-600">{{ description }}</DialogDescription>
        <div class="mt-4">
          <slot />
        </div>
        <DialogClose class="absolute top-4 right-4 rounded px-2 py-1 hover:bg-gray-100" aria-label="Close">×</DialogClose>
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>

A page passes its own button in the #trigger slot and its form in the default slot, and binds v-model:open. as-child makes the trigger use that button instead of wrapping it in another one. You would build a table the same way with plain <table> markup, and a form with native inputs. It costs more time per component, and in return every pixel and every dependency is yours.

For the smallest projects, even primitives can be more than you need. The browser's own <dialog> element, opened with showModal(), already makes the page behind it inert and closes on Escape, and a plain <table> with a few Tailwind classes is enough for a list of twenty customers. Reach for Reka UI when you need the harder widgets: comboboxes, menus, date pickers and anything with roving focus (arrow keys move focus within a group).

Which one to pick

  • A Nuxt app, a team that writes Tailwind, a dashboard or SaaS product: Nuxt UI. It is the shortest path from idea to a polished screen.
  • A data-heavy business app, especially without a designer: PrimeVue in styled mode with Aura. Its table and form components go deepest, and the theme looks finished from day one.
  • A product that should look like Material Design, or an existing Vuetify 3 app: Vuetify 4. Upgrading keeps your knowledge, and the new CSS layers make overrides much less painful.
  • A strong brand design system, or a component kit shared across apps: build on Reka UI and Tailwind. The primitives handle accessibility; your design stays your own.
  • A marketing site or a small app with a few buttons and one form: no library. Native elements with Tailwind are lighter than any of the above.
Check it stuck3 questions from the Component libraries quiz.
Practice now
Was this helpful?

Keep reading