The three libraries ship Vue, React and Angular components, but 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 theming any one of them works the same way as theming 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 there is rarely a reason to write one from scratch, so pick the one closest to the target design and adjust it from there. definePreset takes an existing preset and deep-merges the 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 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 passed in, the stops above it are shades, and 500 is the color itself. One brand hex is enough for a usable ramp.

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

Three layers of tokens

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 edited 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. Overriding twenty components in the same way is a sign that the edit belongs in the semantic layer instead.

primary.color and the tokens next to the stops

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 them. But to get 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.

The shipped themes also use a second way of writing the same thing: 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 as well: 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. For an app with its 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');

That option emits only a few lines of CSS. With the default, system, the output is 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;
}

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. A rule inside a shadow root cannot see the class on <html>, so for components in shadow DOM the libraries mirror the switch onto the shadow host. How that works is in Using PrimeUI components inside a shadow root.

To keep dark mode on permanently, put the class on <html> and leave it there. Setting darkModeSelector to 'none' turns it off, 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 that receives it.

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 whatever the framework.

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 the theme has to change 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 change the theme in the same way, though.

updatePreset merges. Only the paths in the argument 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 merge at the stop level in the same way. Call updatePrimaryPalette({ 500: '{indigo.500}' }) on Aura and the result is a primary whose 500 is indigo while 600 and every other stop are still emerald, which is rarely a usable ramp. To change the primary color, pass the whole scale, which palette builds from one color or one token reference. To nudge a single value, either call works.

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 for switching from Aura to Nora and not for 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 the app’s 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 the app already ships.

cssLayer puts the whole theme inside an @layer. It is false by default, so theme rules land in the unlayered cascade and beat anything written inside a layer. Set it to true and the styles go into a layer named primeui. Pass an object instead to choose the layer name and declare its order relative to the app’s own layers:

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

Try this setting before reaching for !important. When overrides written for the app are losing to component styles, declaring a layer order usually fixes it without raising specificity.

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(...) automatically. 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 a page with two differently themed regions needs.

Reading tokens from the app’s own CSS

The generated variables are a public surface, so the app’s 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.

The names do not have to be assembled 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 it returns light-dark(#10b981,#34d399) rather than another var(). The fallback argument of dt, custom tokens under extend, and how the same tokens reach the Figma kit are covered in Design tokens in PrimeUI.

A reasonable order of operations

Start from a preset that is close to the intended look. Aura is the current house style, and Material and Nora are there for 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 the brand.
  3. Then colorScheme.light and colorScheme.dark, for the handful of tokens where the two modes need different values rather than two stops of one ramp.
  4. Component-level tokens last, and only for genuine one-offs.

Steps 1 and 2 are often enough.