Colors API
ColorApi resolves semantic color tokens — the named values like primary, surface, onPrimary — from palette tones. Each token is a Color that computes its value at read time, respecting the current contrast level and dark mode.
api.colors.get(key)
Returns the Color for a semantic token. Available after the first api.load().
// With sourceColor '#6750A4' and the default variant:
const primary = api.colors.get('primary');
primary.tone; // 40.3 in light mode, 80 in dark
primary.hex; // "#655789"
primary.hue; // 299.4
primary.chroma; // 32.2See Color for the full reading and derivation surface.
api.colors.getAll()
A read-only map of every registered token:
for (const [key, color] of api.colors.getAll()) {
console.log(key, color.hex);
}Adding tokens
The colors option accepts one flat collection of values. A hex string is the simple static form; a Color can keep a dynamic relationship with a palette; and a callback can transform the token already supplied by the variant.
ColorsConfig is the public type exported by @udixio/theme through its configuration barrel. Most applications do not need to name that type: put the collection directly in theme.config.ts and let defineConfig infer it. The type is useful when a library exposes a reusable color layer:
import type { ColorsConfig } from '@udixio/theme';
export const reusableColors: ColorsConfig = {
brandOrange: '#FF5722',
};The runtime method api.colors.addColors() accepts the same collection. A top-level function receives the API and returns the collection; a function used as a token value receives the current token. Both forms are initialized and reapplied by the theme engine when the relevant context changes.
A fixed color
api.colors.addColors({
brandOrange: '#FF5722',
});In theme.config.ts, the same value belongs directly under colors:
import { defineConfig } from '@udixio/tailwind';
export default defineConfig({
sourceColor: '#6750A4',
colors: {
brandOrange: '#FF5722',
},
});Static — it does not follow dark mode or contrast level. Color.fromHex(...) is also accepted when the value is already available as a Color.
Modify an existing token
When the variant or a custom palette already provides the token, use a callback directly in colors to adjust its resolved value. The callback is reapplied lazily, so the source remains dynamic across dark mode, contrast and variant changes:
// theme.config.ts
import { defineConfig } from '@udixio/tailwind';
export default defineConfig({
sourceColor: '#6750A4',
colors: {
surface: (color) => color.withTone(Math.min(100, color.tone + 2)),
surfaceContainer: (color) => color.scaleChroma(0.9),
},
});withTone, withHue, withChroma, rotate and scaleChroma are regular resolution steps. In a colors callback they run before the palette’s chromaMultiplier and adjustTone rules by default:
colors: {
surface: (color) =>
color.withTone(Math.min(100, color.tone + 2)),
}Use afterResolution() when the transformation must be applied to the final value instead:
colors: {
surface: (color) =>
color.afterResolution((resolved) =>
resolved.withTone(Math.min(100, resolved.tone + 2)),
),
}An alias
api.colors.addColors({
ctaBackground: Color.alias('primary'),
});ctaBackground mirrors whatever primary resolves to, always.
From a palette
Create a dynamic Color when the token should take its hue and chroma from a palette. The Color stores the recipe and resolves it with the current theme API:
// theme.config.ts
import { defineConfig } from '@udixio/tailwind';
import {
Color,
avoidBackgroundGap,
contrastAgainst,
onColor,
} from '@udixio/theme';
export default defineConfig({
sourceColor: '#6750A4',
colors: {
highlight: Color.fromPalette('tertiary', {
tone: () => 70,
adjustTone: [contrastAgainst('surface', 3), avoidBackgroundGap()],
}),
onHighlight: Color.fromPalette('tertiary', {
adjustTone: onColor('highlight', 4.5),
}),
surface: (color) => color.withTone(Math.min(100, color.tone + 2)),
},
});| Property | Description |
|---|---|
palette | Source palette, providing hue and chroma. A PaletteRef: a key — 'neutral' — a Palette, or a function of the API. Passing the palette rather than raw numbers preserves the intended chroma when the tone moves. |
tone | The default tone. Defaults to DEFAULT_TONE (50). |
adjustTone | One adjuster, or a list applied in order after the default color customizations. |
chromaMultiplier | Multiplies the palette chroma after the default color customizations. Default 1. The resolved palette parameters are available through color.options. |
Tone adjusters
An adjuster takes the tone the previous step left and returns the next one:
type ToneAdjusterArgs = {
tone: number;
context: Context;
colors: ColorApi;
palettes: PaletteApi;
};
type ToneAdjuster = (args: ToneAdjusterArgs) => number;It receives only the current tone and the theme services needed to resolve it: context, colors and palettes. The full API — including plugins — is not part of the adjuster contract. The built-in adjusters are factories: you give them what identifies the step, they give you the adjuster.
onColor(background, contrast)
The shape of every on* token. Starts from the background’s own tone and pushes it until it meets the contrast — so it ignores the incoming tone, and only makes sense first in a chain.
onBrand: Color.fromPalette('brand', {
adjustTone: onColor('brand', 6),
}),contrastAgainst(background, contrast)
Pushes the incoming tone until it contrasts with background. The tone is left alone if it already clears the ratio — except at a negative contrast level, where it is recomputed so it can be lowered.
adjustTone: contrastAgainst('surface', 4.5),background is a ColorRef: a token key, a Color, or a zero-argument function returning a Color. The function is evaluated lazily, so it can close over the context and colors available in a colors configuration callback:
import { defineConfig } from '@udixio/tailwind';
import { Color, contrastAgainst } from '@udixio/theme';
export default defineConfig({
sourceColor: '#6750A4',
colors: ({ context, colors }) => ({
highlight: Color.fromPalette('tertiary', {
adjustTone: contrastAgainst(
() =>
context.isDark
? colors.get('surfaceBright')
: colors.get('surfaceDim'),
4.5,
),
}),
}),
});contrast is a ContrastSpec. A number selects one of Material’s standard curves — 1.5, 3, 4.5, 6, 7, 9, 11, 21. Note it is a curve, not a fixed ratio: 4.5 means 4.5:1 at contrast level 0, rising to 11:1 at level 1. For anything else, build a ContrastCurve yourself, or pass a function when the curve depends on the context:
colors: ({ context }) => ({
highlight: Color.fromPalette('tertiary', {
adjustTone: [
contrastAgainst('surface', new ContrastCurve(2, 3, 4.5, 8)),
contrastAgainst(
'surface',
() => (context.contrastLevel > 0 ? getCurve(1.5) : undefined),
),
],
}),
}),Returning undefined leaves the tone untouched.
avoidBackgroundGap()
Pushes the tone out of the band where no foreground — light or dark — reaches sufficient contrast. Put it on tokens that act as backgrounds, and never after an exact tone delta, which it would undo.
adjustTone: [contrastAgainst('surface', 4.5), avoidBackgroundGap()],The band is exported as BACKGROUND_TONE_GAP ({ pivot: 57, lightFloor: 65, darkCeiling: 49 }). These are Material spec values; changing them takes the theme out of conformance.
applyToneDelta(delta)
Keeps two tokens a given distance apart, for cases where they must stay distinct without being in a background/foreground relationship.
adjustTone: applyToneDelta({
relativeTo: 'brandContainer',
delta: 5,
polarity: 'relativeDarker',
constraint: 'farther',
}),The polarity is read from the token declaring it — the example says “I must be at least 5 darker than brandContainer”.
| Polarity | Meaning |
|---|---|
darker / lighter | Absolute direction. |
relativeDarker / relativeLighter | Follows the surface trend: toward white in light mode, toward black in dark mode. |
| Constraint | Meaning |
|---|---|
exact | Pins the tone at exactly delta away. |
nearer | Keeps it within delta. |
farther | Keeps it at least delta away. |
arbitrateBackgrounds(first, second, contrast)
Finds a tone clearing the contrast against two backgrounds at once. Leaves the tone alone when it already clears both.
Chaining
A list applies in order, each step receiving what the previous returned:
api.colors.addColors(({ palettes }) => ({
brand: Color.fromPalette('brand', {
tone: () => 70,
adjustTone: [
applyToneDelta({
relativeTo: 'brandContainer',
delta: 5,
polarity: 'relativeDarker',
constraint: 'farther',
}),
contrastAgainst('surface', 4.5),
avoidBackgroundGap(),
],
}),
onBrand: Color.fromPalette('brand', {
adjustTone: onColor('brand', 6),
}),
}));Inside adjustTone, there is no hidden step: the list runs in the order you declare it.
Writing your own adjuster
An adjuster is any function of the same shape, so a custom step is just another element of the list:
Color.fromPalette('brand', {
adjustTone: [
contrastAgainst('surface', 4.5),
({ context, palettes, tone }) =>
context.isDark ? Math.min(tone, 80) : Math.max(tone, 20),
],
});Two pure helpers are exported for hand composition, when a step is conditional and a flat list cannot express it:
contrastTone(tone, background, contrast, args)— the core ofcontrastAgainstandonColor.backgroundGapTone(tone)— the core ofavoidBackgroundGap.
adjustTone: (args) =>
args.context.contrastLevel > 0
? backgroundGapTone(contrastTone(args.tone, 'surface', 1.5, args))
: args.tone,Example: a highlight pair
import {
avoidBackgroundGap,
Color,
contrastAgainst,
FontPlugin,
loader,
onColor,
} from '@udixio/theme';
import { TailwindPlugin } from '@udixio/tailwind';
const api = await loader({
sourceColor: '#6750A4',
colors: {
highlight: Color.fromPalette('tertiary', {
tone: () => 70,
adjustTone: [contrastAgainst('surface', 3), avoidBackgroundGap()],
}),
onHighlight: Color.fromPalette('tertiary', {
adjustTone: onColor('highlight', 4.5),
}),
},
plugins: [new FontPlugin(), new TailwindPlugin()],
});
// TailwindPlugin generates:
// --color-highlight, --color-on-highlight
// .bg-highlight, .text-on-highlight, …