Appearance
Adapting PrimeVue Components
How to properly adapt PrimeVue components to our component system.
Unstyled Mode
PrimeVue has two theming modes:
- Styled - default mode, where styling is applied through configuration, but it's dynamic CSS generation system is not fit for working inside ShadowDOM (which we do to integrate with Legacy frontend).
- Unstyled - mode we are using - components are treated as headless while we apply styling.
To work with unstyled mode we simply need to pass unstyled and proper theming via pt options - and ptViewMerge as mergeProps callback:
vue
<template>
<PrimeButton
unstyled
:pt="theme"
:pt-options="{
mergeProps: ptViewMerge,
}"
>
</template>
<script lang="ts">
import PrimeButton from "primevue/button";
import { ptViewMerge } from "~/core/components";
</script>unstyledputs component in unstyled mode where it doesn't apply any stylesptallows us to customize styles and other properties of the componentpt-options- we set custom helper that uses tailwind-merge to merge different css classes
Variants and [data-p]
Use tailwind-variants for the public visual API of our components. In PrimeVue adapters, the generated slot classes usually map directly into pt, because PrimeVue pass-through styling targets several component parts, not just the root element:
vue
<script setup lang="ts">
import { buttonVariants } from "./variants";
const theme = computed(() => {
const classes = buttonVariants({
severity: props.severity,
size: props.size,
outlined: props.outlined,
});
return {
root: {
class: classes.root(),
},
icon: {
class: classes.icon(),
},
};
});
</script>Prefer tailwind-variants for classes derived from our wrapper props: severity, variant, size, outlined, icon placement and similar visual choices.
[data-p-*]
PrimeVue uses another method for variants – setting data-p-* attribute. For example it sets the following attributes on a button:
html
<button
data-p-severity="success"
data-p-disabled="false"
data-p="rounded"
...
></button>You can match styles based on selectors like this:
css
button[data-p~="rounded"] {
border-radius: 0.3rem;
}To make it compatible with Volt UI components - we are using custom Tailwind CSS variants:
css
@custom-variant p-active (&[data-p~="active"],&[data-p-active="true"]);and usage looks like this:
ts
const root = "text-black p-active:text-primary";You can see full list here.
[data-p] vs Tailwind Variants
Which one should you use then?
- Prefer
tailwind-variantsfor visuals controlled by our wrapper API, like severity, variant, size, outlined vs filled and icon layout. - Prefer
[data-p]for internal PrimeVue state that is already reflected on the DOM and is hard to reproduce from wrapper props, likep-active,p-selected,p-invalidor overlay state. - If a
data-p-*attribute mirrors a prop we already control, usetailwind-variantsinstead. The wrapper should own its public interface. - Think through the interface instead of copying PrimeVue props blindly. Volt components are a good starting point for structure and selectors, but we should remove dark-mode classes, translate styles to our tokens and keep only the customization points our component system wants to expose.
Think through the slots
PrimeVue typically has a lot of slots allowing for heavy customization. Let's consider Message slots:
default- contenticon- allows for overriding whole icon with custom elementscloseicon- allows for overriding close icon with custom elementscontainer- allows to override top level container
This is powerful for customizing PrimeVue components but it's completely unnecessary for our own components - we don't want code all over the place to heavily customize how the components look like! Instead we want our own components to constrain how things will look like - in single place!
Because of that:
- we want to use
iconandcloseiconto override icon handling, but we don't want to expose this to the rest of the system - we don't want
containerat all because it allows for full override of contents - we want to keep
defaultbecause this is how we pass content
So the resulting code would look similar to this:
vue
<template>
<PrimeMessage ...>
<!-- pass default slot forward -->
<template #default>
<slot></slot>
</template>
<!-- override close icon with lucide icon -->
<template #closeicon>
<X :class="variants.close()" />
</template>
<!-- enable component-based icons -->
<template #icon>
<component :is="props.icon" :class="variants.icon()" />
</template>
</PrimeMessage>
</template>Adapting icons
PrimeVue components typically only support using font-based components:
vue
<Button icon="pi pi-times" label="Close" />This was good in the 90', but in 2026 everyone uses SVG icons. And we use Lucide.
@primevue/icons
For it's own components PrimeVue uses a small subset of PrimeVue Icons published as SVGs in @primevue/icons package. But they don't expose this ability for everyone else yet.
To support SVG icons - where needed, we need to alter the support for icons like this:
vue
<script lang="ts">
import { Trash } from "lucide-vue-next";
import { Button } from `~/core/components`;
</script>
<template>
<Button label="Delete" :icon="Trash" />
</template>Starting from Volt components
PrimeVue has a set of components built in top of Prime - Volt.
Volt is similar in philosophy to Shadcn:
- a set of components built on top of headless library (in this case - PrimeVue in unstyled mode)
- but you don't install them, you copy them over to the repository
- then you customize them to your own needs
You can simply downloads components from https://github.com/primefaces/primevue/tree/master/apps/volt/volt and save it to components folder.
Volt components act as a starting point for styling, but there is a lot of changes that need to be made:
- Get rid of dark mode
- Treat Prime component as a headless component and actually rethink what we need from the components - what variants, colors, sizes and what slots to expose
- Convert to
tailwind-variants - Adapt icons
- Move to a folder structure (
src/components/component-name)- make sure there is a
index.tsfile with export - make sure variants are in
variants.ts
- make sure there is a
- Document it with a
.story.vuefile