On the surface the three libraries look different: Vue components, React components, Angular components. Underneath they share one theming layer, the @primeuix/styled engine, wrapped by the presets in @primeuix/themes.

The design team works in a single Figma file that is the source of truth, and the same design tokens come out of it for all three libraries. So if you have themed one of them you already know how to theme the others, and the only thing that changes is the setup call at the start.

Presets

A preset is a complete set of design tokens. Aura, Lara, Nora and Material all ship in @primeuix/themes, and you almost never want to write one from scratch, so pick the one closest to what you have in mind and adjust it from there. definePreset takes an existing preset and deep-merges your overrides on top of it:

import { definePreset } from '@primeuix/themes';
import Aura from '@primeuix/themes/aura';

const MyPreset = definePreset(Aura, {
    semantic: {
        primary: {
            50: '{indigo.50}',
            100: '{indigo.100}',
            200: '{indigo.200}',
            300: '{indigo.300}',
            400: '{indigo.400}',
            500: '{indigo.500}',
            600: '{indigo.600}',
            700: '{indigo.700}',
            800: '{indigo.800}',
            900: '{indigo.900}',
            950: '{indigo.950}'
        }
    }
});

Changing the primary color is the customization most people do first. Aura’s primary maps to emerald by default and the code above points it at indigo instead, and every component that reads the primary color follows along: buttons, checkboxes, focus rings.

Nothing has been applied to the page yet. definePreset returns a merged token tree and leaves the running theme alone, so you assign the result to a variable and hand that variable to the setup call.

Eleven lines to name one color ramp is a lot of typing. palette expands a single value into the full 50 to 950 scale:

import { definePreset, palette } from '@primeuix/themes';

const MyPreset = definePreset(Aura, {
    semantic: {
        primary: palette('{indigo}')
    }
});

Given a token path, palette emits references, one per stop. Given a hex color it does the color math instead: the stops below 500 are tints of the color you passed, the stops above it are shades, and 500 is the color itself. One brand hex gets you a usable ramp.

The {indigo.500} syntax is a token reference, not a hex value. You are pointing at a primitive that is already defined somewhere else, and when you change that primitive later everything pointing at it moves with it.

Three layers of tokens

It helps to know which layer you are editing.

Primitive tokens are raw values: the full color ramps (indigo, emerald, slate, and so on), border radii. On their own they do not mean anything, they are just colors and numbers with names.

Semantic tokens give those values a job. primary is the main color, surface is the background ramp, formField controls input styling, content covers panel bodies and text covers copy. This is the layer you will edit most often, because changing primary here changes everything that depends on primary in one go.

Component tokens sit on top, one group per component, and they mostly point back at the semantic layer rather than at raw values. Button’s radius is {form.field.border.radius} and its focus ring is {focus.ring.width}. Because of that indirection, one edit in the semantic layer reaches the whole suite.

Primitive tokens feed semantic tokens, which feed component tokens

Go to the component layer last. If you find yourself overriding twenty components in the same way, the edit belongs in the semantic layer instead.

The stops are not the whole story

A color ramp under semantic.primary is only half of what primary means. Aura defines four more tokens next to the stops:

primary: {
    // 50 to 950 stops
    color: 'light-dark({primary.500}, {primary.400})',
    contrastColor: 'light-dark(#ffffff, {surface.900})',
    hoverColor: 'light-dark({primary.600}, {primary.300})',
    activeColor: 'light-dark({primary.700}, {primary.200})'
}

Components read these four rather than the stops underneath them. In Aura, twenty five component token files reference {primary.color} and only two reference {primary.500}. Checkbox, for example, sets checkedBackground: '{primary.color}' and checkedHoverBackground: '{primary.hover.color}'.

Swapping the ramp is still usually enough, since color and the three next to it are defined in terms of the stops and follow whatever you put there. But if you want a primary that is darker in light mode than 500, edit primary.color and leave the ramp alone.

Surfaces and dark mode

Surface tokens are the greys: page backgrounds, card backgrounds, borders. They change between light and dark, and light-dark() is how both modes go into one declaration. Aura writes them like this:

const MyPreset = definePreset(Aura, {
    semantic: {
        surface: {
            0: '#ffffff',
            50: 'light-dark({stone.50}, {zinc.50})',
            100: 'light-dark({stone.100}, {zinc.100})',
            200: 'light-dark({stone.200}, {zinc.200})',
            300: 'light-dark({stone.300}, {zinc.300})',
            400: 'light-dark({stone.400}, {zinc.400})',
            500: 'light-dark({stone.500}, {zinc.500})',
            600: 'light-dark({stone.600}, {zinc.600})',
            700: 'light-dark({stone.700}, {zinc.700})',
            800: 'light-dark({stone.800}, {zinc.800})',
            900: 'light-dark({stone.900}, {zinc.900})',
            950: 'light-dark({stone.950}, {zinc.950})'
        }
    }
});

There is no second stylesheet for dark mode anywhere in this. Aura’s own default is slate for light and zinc for dark, and the block above keeps the dark half and swaps the light half to stone, which is a warmer neutral.

You will also see a second way of writing the same thing in the shipped themes: a colorScheme block that splits the tree into a light half and a dark half.

semantic: {
    colorScheme: {
        light: {
            primary: { color: '{primary.950}', contrastColor: '#ffffff' }
        },
        dark: {
            primary: { color: '{primary.50}', contrastColor: '{primary.950}' }
        }
    }
}

Both forms end up as the same generated CSS. Use light-dark() when the two values are the same token at two different stops, and colorScheme when the two halves really do diverge, which happens often enough with primary.color, highlight and anything to do with contrast. Watch the nesting while you are in there: colorScheme needs a light or a dark key under it, and a block written directly under colorScheme is treated as scheme independent instead.

The dark mode switch

By default the libraries follow the operating system setting. If you have your own toggle in the UI, tell the theme which selector to watch:

options: {
    darkModeSelector: '.my-app-dark';
}

Then flipping it is one line:

document.documentElement.classList.toggle('my-app-dark');

What that option emits is smaller than most people expect. With the default, system, you get this:

:root,
:host {
    color-scheme: light;
}
@media (prefers-color-scheme: dark) {
    :root,
    :host {
        color-scheme: dark;
    }
}

With darkModeSelector: '.my-app-dark' the media query is replaced by a class rule:

:root,
:host {
    color-scheme: light;
}
.my-app-dark {
    color-scheme: dark;
}
:host-context(.my-app-dark) {
    color-scheme: dark;
}

The option sets the CSS color-scheme property and does nothing else. Every token value stays a single light-dark(...) declaration and the browser picks the branch it needs, so there is no duplicated dark block to fight with and no specificity contest between a light rule and a dark one. The :host-context line carries the switch across a shadow boundary, which is how components in shadow DOM follow the same toggle.

If you want dark mode on permanently, put the class on <html> and leave it there. If you want it off, set darkModeSelector to 'none' and only the light color-scheme declaration is emitted.

Setting it up

This is the only part that differs per framework. The preset and the options are the same object in all three, and what changes is the function you hand that object to.

PrimeVue:

import PrimeVue from 'primevue/config';

app.use(PrimeVue, {
    theme: {
        preset: MyPreset,
        options: {
            darkModeSelector: '.my-app-dark'
        }
    }
});

PrimeReact:

import { PrimeReactProvider } from '@primereact/core';

<PrimeReactProvider theme={{ preset: MyPreset, options: { darkModeSelector: '.my-app-dark' } }}>{children}</PrimeReactProvider>;

PrimeNG:

import { providePrimeNG } from 'primeng/config';

providers: [
    providePrimeNG({
        theme: {
            preset: MyPreset,
            options: { darkModeSelector: '.my-app-dark' }
        }
    })
];

There is one wrinkle in the imports. The PrimeReact provider lives in @primereact/core rather than in the primereact package itself, while PrimeVue and PrimeNG both expose theming from a config subpath of the main package. The theme object is identical in all three. None of them re-export the preset helpers either, so definePreset and the rest come from @primeuix/themes no matter which framework you are in.

A team running Vue on one product and Angular on another writes the brand preset once and uses the same file in both.

Changing colors at runtime

Sometimes you need to switch themes after the app has started, for a user preference or a white-label deployment. There are helpers for that:

import { updatePreset, updatePrimaryPalette, updateSurfacePalette, palette } from '@primeuix/themes';

updatePrimaryPalette(palette('{green}'));

All of them apply immediately, with no reload. They mutate the shared theme singleton, rebuild the token map and emit a change event that the libraries listen for.

They do not all do the same kind of thing, though, and the difference will bite you at some point.

updatePreset merges. Only the paths you supply change and everything else is preserved, so it is the safe one for a partial edit:

updatePreset({
    semantic: { primary: { color: '{amber.500}' } },
    components: { button: { root: { paddingX: '1.25rem' } } }
});

updatePrimaryPalette and updateSurfacePalette replace the palette instead, so any stop you leave out is dropped rather than kept. Call updatePrimaryPalette({ 500: '{indigo.500}' }) on Aura and you end up with a primary whose 500 is indigo while 600 is still emerald. Pass the whole scale, which you can build with palette. If you only meant to nudge one value and want the rest of the ramp left where it was, use updatePreset.

usePreset replaces the active preset entirely rather than merging into it. Tokens that are absent from the argument are gone rather than inherited, so use it when you are switching from Aura to Nora and not when you are tweaking a few values.

Options beyond dark mode

The options object carries four more settings, and they all decide how the theme meets the rest of your CSS.

prefix renames every generated variable. The default is p, which is where --p-primary-color and --p-surface-100 come from. Change it if those names would collide with something you already ship.

cssLayer puts the whole theme inside an @layer. It is false by default, so theme rules land in the unlayered cascade and beat anything you wrote inside a layer. Set it to true and the styles go into a layer named primeui. Pass an object instead if you want to name the layer yourself and declare its order relative to yours:

options: {
    cssLayer: {
        name: 'primeui',
        order: 'theme, base, primeui, utilities'
    }
}

Try this setting before you try !important. When your own overrides are losing to component styles, declaring a layer order fixes it far more often than raising specificity does.

cssVariables decides whether tokens become CSS custom properties at all. Left at true, a token resolves to var(--p-a-b). Set to false, component variables are not emitted and tokens resolve to their literal values, with the light and dark variants folded into light-dark(...) for you. scoped moves component variables off :root,:host and onto each component’s own selector, while primitive and semantic tokens stay global either way, which is what you need when one page has two differently themed regions on it.

Reading tokens from your own CSS

The generated variables are a public surface, so your own components can read them and stay in step with the theme as it changes. --p-primary-color, --p-surface-100, --p-text-muted-color and the rest are all there.

The naming rule is mechanical: take the token path, kebab-case it, join it with dashes and put the prefix in front. semantic.formField.paddingX becomes --p-form-field-padding-x. Structural keys carry no segment of their own, so colorScheme, light, dark and root disappear from the name, and colorScheme.light.surface.100 and surface.100 both produce --p-surface-100.

If you would rather not assemble the names by hand, dt returns the reference and $dt returns the whole record:

import { dt, $dt } from '@primeuix/themes';

dt('primary.color'); // 'var(--p-primary-color)'
$dt('primary.color'); // { name, variable, value }

$dt(...).value resolves the reference chain all the way down, so on stock Aura you get light-dark(#10b981,#34d399) back rather than another var().

A reasonable order of operations

Start from a preset that is close to what you want. Aura is the current house style, and Material and Nora are there if you need a different feel.

  1. Change primary first, because it is one edit and it moves the most.
  2. Then surfaces, if the greys feel too warm or too cool for your brand.
  3. Then colorScheme.light and colorScheme.dark, for the handful of tokens where the two modes need genuinely different values rather than two stops of one ramp.
  4. Component-level tokens last, and only for genuine one-offs.

Most teams get what they wanted after the first two and stop there.