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.
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.
- Change
primaryfirst, because it is one edit and it moves the most. - Then surfaces, if the greys feel too warm or too cool for your brand.
- Then
colorScheme.lightandcolorScheme.dark, for the handful of tokens where the two modes need genuinely different values rather than two stops of one ramp. - Component-level tokens last, and only for genuine one-offs.
Most teams get what they wanted after the first two and stop there.