Component libraries
Choose between a styled library, headless primitives and copied-in components, set up Nuxt UI with <UApp> and its CSS import, wrap what you use, and let axe check the result.
What you'll learn
When you finish, you can do these.- 1Choose between a styled, headless or copy-in library
- 2Set up Nuxt UI with its module, CSS import and UApp
- 3Wrap library components in your own, such as AppButton
- 4Test a page with axe and fix low-contrast colours
Picture the cart page of a small online shop that sells travel gear. Its Save for later button shows a toast, a message that pops up and fades, and Clear cart asks first in a dialog, a small window over the page. Built by hand, each piece needs its own rules for the keyboard and for screen readers, apps that read the page aloud. A shop has thirty such pieces. A component library packages them, built and tested by people who do nothing else.
You need one idea from Accessible components: a dialog must take the focus, the place where keys act, keep Tab presses inside, close on Escape and give focus back to the button that opened it. Here you compare three kinds of library, set up one, Nuxt UI, in a Nuxt app (Nuxt is a framework built on Vue), wrap its button and test the page. The Code tab holds the app's files, to copy into a Nuxt project.
Three kinds of library
Component libraries come in three kinds, by how much they decide for you:
- A styled library ships finished components with a look: Nuxt UI, Vuetify (Google's Material Design look) and PrimeVue.
- A headless library ships behaviour only: Reka UI's dialog handles focus, Escape and ARIA, the attributes that tell a screen reader what the screen shows, with no CSS.
- A copy-in tool such as shadcn-vue sits between:
npx shadcn-vue@latest add buttonwrites a styled Reka UI component into your project, and from then on it is your code.
Styled and copy-in libraries compare like this:
Install a package: Nuxt UIFinished, accessible components from a package; one update fixes all of them. The look changes through colour variables, settings and class overrides, within limits.
Own the code: shadcn-vueThe same Reka UI behaviour with a default style, copied into your project as .vue files. Edit anything. Updates are manual, one component at a time.
Reka UI alone fits an app with a complete design of its own, where nothing may look like a library default.
Nuxt UI in four files
Nuxt UI has over a hundred components built on Reka UI and Tailwind CSS v4. After npm install @nuxt/ui tailwindcss, it takes three setup files. Nuxt's settings file, nuxt.config.ts, adds the @nuxt/ui module, which plugs Nuxt UI into Nuxt, and loads the main stylesheet:
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
})
The stylesheet imports Tailwind, then Nuxt UI's component styles and the colour variables that classes such as bg-primary read:
@import "tailwindcss";
@import "@nuxt/ui";
app.vue, the component around every page, wraps <NuxtPage />, where the current page shows, in <UApp>:
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
<UApp> holds the list where toasts appear, the provider every tooltip (a hint shown on hover) must sit inside, and overlays opened from code.
The fourth file, app/pages/index.vue, is the cart page. Its buttons are your own AppButton (next section). Save for later calls save(), which shows the Cart saved toast with toast.add() from Nuxt UI's useToast(). Clear cart opens UModal, Nuxt UI's dialog:
<AppButton @click="save">Save for later</AppButton>
<UModal v-model:open="open" title="Remove every item?" description="This empties the cart.">
<AppButton tone="danger" :disabled="!items.length">Clear cart</AppButton>
<template #footer>
<AppButton class="rounded-md" @click="open = false">Keep them</AppButton>
<AppButton tone="danger" @click="clear">Clear</AppButton>
</template>
</UModal>
Clear cart, the trigger that opens UModal, sits between its tags. v-model:open keeps open true while the dialog shows, so Keep them closes it with open = false. #footer holds the bottom buttons.
Under the look it is a Reka UI dialog: focus moves to its close button, Tab stays inside, Escape closes it and focus returns to Clear cart, for free.
Wrap what you use, then test it
The cart page never writes <UButton>, Nuxt UI's button, only <AppButton>, your wrapper around it. Pages ask for your prop, tone="danger"; only the wrapper knows the library calls it color="error", so a swap to another library touches one file per component. The wrapper is app/components/AppButton.vue:
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
const props = defineProps<{ tone?: 'primary' | 'danger'; class?: string }>()
const classes = computed(() => twMerge('rounded-full font-semibold', props.class))
</script>
<template>
<UButton :color="tone === 'danger' ? 'error' : 'primary'" :class="classes">
<slot />
</UButton>
</template>
rounded-full, Tailwind's class for pill-shaped corners, is on every button, but the page gives Keep them class="rounded-md", slightly rounded corners. Left to Vue, that class would join rounded-full on UButton, the template's top element, and the order of their rules in the stylesheet would pick the winner.
So class is a prop, and twMerge from the tailwind-merge package merges the lists, keeping the later of two clashing classes: twMerge('rounded-full font-semibold', 'rounded-md') returns 'font-semibold rounded-md'. Other attributes, such as @click, pass through to UButton.
A library ships a look, not a guarantee. The accessibility checker axe runs in Playwright, a browser test tool, through @axe-core/playwright. The first test in e2e/a11y.spec.ts checks the cart page's main element, the part you wrote:
const results = await new AxeBuilder({ page }).include('main').analyze()
expect(results.violations).toEqual([])
violations lists each broken rule, and the test expects none. The first run listed color-contrast for both solid buttons: the contrast of white on Nuxt UI's default green is 2.21 to 1, on its red 3.8 to 1, under the 4.5 to 1 that WCAG, the web accessibility guidelines, asks for. So the Code tab's main.css points --ui-primary and --ui-error at their darker 700 shades.
A second test checks that Escape closes the dialog and gives focus back to Clear cart. Playwright's setup is its own lesson, End-to-end tests with Playwright.
What goes wrong
The first mistake is an app.vue without <UApp>. A page that calls toast.add() shows no toast and logs no warning, because the list that renders toasts is inside <UApp>. A page with a UTooltip, Nuxt UI's tooltip, does not render at all: the server answers with a 500 error, Injection `Symbol(TooltipProviderContext)` not found. Component must be used within `TooltipProvider`.
<template>
<NuxtPage />
</template>
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
The second is a main.css with the Tailwind line but not the Nuxt UI one. Every component renders as plain HTML: Save for later is a transparent box with black text, because bg-primary reads a variable nothing defines. Even the dialog stays open after Escape: its closing animation is in that stylesheet.
@import "tailwindcss";
@import "tailwindcss";
@import "@nuxt/ui";
What to remember
- Styled libraries (Nuxt UI, Vuetify, PrimeVue) decide the look, headless ones (Reka UI) ship behaviour only, and copy-in tools (shadcn-vue) put styled code in your repo.
- Check the licence and the last release: PrimeVue 5 is commercial above a small team, and Headless UI for Vue is unmaintained.
- Nuxt UI needs the module,
@import "@nuxt/ui"after Tailwind, and<UApp>around the app for toasts, tooltips and overlays opened from code. - Wrap library components in your own,
AppButtonoverUButton, merging class props withtailwind-merge, so a swap touches one file per component. - A library ships a look, not a guarantee: run axe on your page and fix what it finds, such as low contrast.
In your project
Do these in a real project. Each one says how to confirm it worked.- Copy the Code tab's files into a Nuxt project and install
@nuxt/ui,tailwindcssandtailwind-merge. Click Clear cart and press Tab: focus moves between the dialog's buttons, never to the page behind. Press Escape: the focus ring is back on Clear cart. Delete<UApp>fromapp.vueand click Save for later: nothing appears and nothing warns. Put it back. - In
app/pages/index.vue, deleteclass="rounded-md"from Keep them: it turns pill-shaped like the other buttons. Then addsize="xl"to Save for later: it grows, becausesizeis not anAppButtonprop and passes through toUButton. - In a project with Playwright set up, install
@axe-core/playwright, adde2e/a11y.spec.tsand runnpx playwright test: both tests pass. Remove the two--ui-lines frommain.cssand run again: the first test fails andviolationslistscolor-contrast. Put the lines back.