Skip to content

Adapting PrimeVue Components

How to properly adapt PrimeVue components to our component system.

Unstyled Mode

PrimeVue has two theming modes:

  1. 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).
  2. 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>
  • unstyled puts component in unstyled mode where it doesn't apply any styles
  • pt allows us to customize styles and other properties of the component
  • pt-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?

  1. Prefer tailwind-variants for visuals controlled by our wrapper API, like severity, variant, size, outlined vs filled and icon layout.
  2. Prefer [data-p] for internal PrimeVue state that is already reflected on the DOM and is hard to reproduce from wrapper props, like p-active, p-selected, p-invalid or overlay state.
  3. If a data-p-* attribute mirrors a prop we already control, use tailwind-variants instead. The wrapper should own its public interface.
  4. 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 - content
  • icon - allows for overriding whole icon with custom elements
  • closeicon - allows for overriding close icon with custom elements
  • container - 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 icon and closeicon to override icon handling, but we don't want to expose this to the rest of the system
  • we don't want container at all because it allows for full override of contents
  • we want to keep default because 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:

  1. Get rid of dark mode
  2. 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
  3. Convert to tailwind-variants
  4. Adapt icons
  5. Move to a folder structure (src/components/component-name)
    • make sure there is a index.ts file with export
    • make sure variants are in variants.ts
  6. Document it with a .story.vue file