Variants API
A variant answers one question: given a single source colour, which palettes does the theme get, and what tone does each token take. The five built-in ones — TonalSpot, Neutral, Vibrant, Expressive, Udixio — are ordinary values built with the same variant() call you have access to. Nothing about them is privileged.
import { variant, Variants } from '@udixio/theme';
export const myVariant = variant({
name: 'mine',
palettes: {
primary: ({ sourceColor }) => sourceColor,
secondary: ({ sourceColor }) => sourceColor.scaleChroma(0.5),
tertiary: ({ sourceColor }) => sourceColor.rotate(60),
neutral: ({ sourceColor }) => ({ hue: sourceColor.hue, chroma: 5 }),
error: () => ({ hue: 25, chroma: 84 }),
},
customPalettes: ({ sourceColor }, color) => ({
hue: color.hue,
chroma: sourceColor.chroma,
}),
colors: Variants.TonalSpot.colors,
});Then use it:
defineConfig({
sourceColor: '#6750A4',
variant: myVariant,
});VariantOptions
ColorsConfig is exported by @udixio/theme. In an application, prefer putting colors directly in theme.config.ts; the named type is mainly useful when defining a reusable variant or color layer.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | ✓ | Unique identifier. getVariantByName() resolves the built-ins by it. |
palettes | Record<string, PaletteCallback> | ✓ | One callback per palette role. |
customPalettes | (context, color) => { hue, chroma } | ✓ | How an extra palette declared in the config is derived from the colour given. |
colorsFromCustomPalette | (key) => ColorsConfig | — | Which tokens each custom palette generates. Defaults to the primary recipes. |
colors | ColorsConfig | — | The tokens themselves. Reuse a built-in set such as Variants.TonalSpot.colors. |
palettes
One callback per palette, each receiving the Context and returning a hue and a chroma. A Color has both, so returning one directly works — its tone is ignored, since a palette spans every tone. The optional base argument is supplied when the same callback shape is used to override a palette in theme configuration; variant callbacks themselves define the base recipe and do not receive one.
palettes: {
// Derived from the source colour
primary: ({ sourceColor }) => sourceColor,
secondary: ({ sourceColor }) => sourceColor.rotate(5).scaleChroma(0.625),
// Reacting to dark mode
neutral: ({ sourceColor, isDark }) => ({
hue: sourceColor.hue,
chroma: isDark ? 6 : 4,
}),
// A fixed palette, ignoring the source entirely
error: () => ({ hue: 25, chroma: 84 }),
}The engine expects primary, secondary, tertiary, neutral and error if you reuse Variants.TonalSpot.colors, because those are the palettes it reads. Define fewer only if you also supply your own colors.
Hue helpers
Two helpers cover the hue maths the Material variants use.
getRotatedHue(sourceColor, breakpoints, rotations) shifts the hue by a different amount depending on which band it falls in:
tertiary: ({ sourceColor }) => ({
hue: getRotatedHue(sourceColor, [0, 20, 71, 161, 333, 360], [-40, 48, -32, 40, -32]),
chroma: 28,
}),A source hue of 30 falls in the [20, 71) band, so it is rotated by 48 to 78.
getPiecewiseHue(sourceColor, breakpoints, hues) picks an absolute hue per band rather than a shift — used for error, so that a red-ish theme does not get an error colour indistinguishable from its primary:
error: ({ sourceColor }) => ({
hue: getPiecewiseHue(sourceColor, [0, 3, 13, 23, 33, 43, 153, 273, 360], [12, 22, 32, 12, 22, 32, 22, 12]),
chroma: 60,
}),Both leave the hue untouched when it falls outside every band.
customPalettes
Called when a user adds a palette of their own through the config:
defineConfig({
variant: myVariant,
palettes: { brand: '#FF5722' },
});It receives the Context and the Color the user gave, and decides how much of that colour to honour. Taking the hue but imposing the theme’s chroma keeps custom palettes in step with the rest:
customPalettes: ({ sourceColor }, color) => ({
hue: color.hue,
chroma: sourceColor.chroma,
}),Taking both keeps the user’s colour exactly:
customPalettes: (_, color) => color,colors
The tokens themselves. Each built-in standard variant carries its own full Material token set, resolved against that variant’s palettes. To start a custom variant from one of them, reuse for example Variants.TonalSpot.colors. Each value follows the same ColorsConfig contract as the theme configuration: a hex string, a Color, or a transformation of an existing colour.
To change a few tokens, spread the defaults and override:
import { Color, Variants, contrastAgainst, avoidBackgroundGap } from '@udixio/theme';
colors: (api) => ({
...(typeof Variants.TonalSpot.colors === 'function'
? Variants.TonalSpot.colors(api)
: Variants.TonalSpot.colors),
// A flatter surface than Material's
surface: Color.fromPalette('neutral', {
tone: () => (api.context.isDark ? 6 : 99),
}),
}),To define them all yourself, see Colors API for the adjusters. The shape of a token is always the same:
primary: Color.fromPalette('primary', {
tone: () => tMaxC(palettes.get('primary')),
adjustTone: [contrastAgainst('surface', 4.5), avoidBackgroundGap()],
}),Tone helpers
tMaxC(palette, lowerBound?, upperBound?, chromaMultiplier?) finds the tone at which a palette reaches its most saturated point, clamped to the bounds. tMinC(palette, lowerBound?, upperBound?) does the opposite. They are how the standard variants pick an accent tone without hard-coding a number that only works for some hues:
tone: () => tMaxC(palettes.get('primary'), 0, 90),Color.isYellow(hue), Color.isCyan(hue) and Color.isBlue(hue) mark the hue ranges that need special handling — yellows go washed out at high tones, cyans cannot reach high chroma at all:
tone: () => tMaxC(palettes.get('primary'), 0, Color.isCyan(palettes.get('primary').hue) ? 88 : 98),colorsFromCustomPalette
Optional. When set, it decides which tokens a custom palette generates. Without it, a palette named brand produces brand, onBrand, brandContainer, onBrandContainer and the fixed roles, following the same recipes as primary.
colorsFromCustomPalette: (key) => ({
[key]: Color.fromPalette(key, {
tone: () => 50,
adjustTone: [contrastAgainst('surface', 4.5), avoidBackgroundGap()],
}),
[`on${key[0].toUpperCase()}${key.slice(1)}`]: Color.fromPalette(key, {
adjustTone: onColor(key, 6),
}),
}),Starting from an existing variant
paletteCallbacks exposes the callbacks as declared, so the shortest route to a small change is to spread one variant into another:
import { Variants, variant } from '@udixio/theme';
export const warmer = variant({
name: 'warmer',
palettes: {
...Variants.TonalSpot.paletteCallbacks,
neutral: ({ sourceColor }) => sourceColor.rotate(20).withChroma(8),
},
customPalettes: Variants.TonalSpot.customPalettes,
colors: Variants.TonalSpot.colors,
});Note it is paletteCallbacks, not palettes: the latter returns the palettes already instantiated for a given context, through palettesFor(context).
udixioVariant is the one worth reading first: it departs furthest from the spec, resolving accents through minContrastTone(), a threshold that scales with the contrast level rather than through Material’s contrast curves: the source tone is kept where it is accessible, and re-chosen where it is not.
Registering it
getVariantByName() resolves the built-ins by name, which is how the React worker rebuilds a theme from a serialised snapshot. It does not know about yours, so pass your variant by value rather than by name if you serialise a theme.