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().

ts
// 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.2

See Color for the full reading and derivation surface.

api.colors.getAll()

A read-only map of every registered token:

ts
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:

ts
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

ts
api.colors.addColors({
  brandOrange: '#FF5722',
});

In theme.config.ts, the same value belongs directly under colors:

ts
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:

ts
// 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:

ts
colors: {
  surface: (color) =>
    color.withTone(Math.min(100, color.tone + 2)),
}

Use afterResolution() when the transformation must be applied to the final value instead:

ts
colors: {
  surface: (color) =>
    color.afterResolution((resolved) =>
      resolved.withTone(Math.min(100, resolved.tone + 2)),
    ),
}

An alias

ts
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:

ts
// 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)),
  },
});
PropertyDescription
paletteSource 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.
toneThe default tone. Defaults to DEFAULT_TONE (50).
adjustToneOne adjuster, or a list applied in order after the default color customizations.
chromaMultiplierMultiplies 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:

ts
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.

ts
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.

ts
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:

ts
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:

ts
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.

ts
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.

ts
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”.

PolarityMeaning
darker / lighterAbsolute direction.
relativeDarker / relativeLighterFollows the surface trend: toward white in light mode, toward black in dark mode.
ConstraintMeaning
exactPins the tone at exactly delta away.
nearerKeeps it within delta.
fartherKeeps 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:

ts
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:

ts
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 of contrastAgainst and onColor.
  • backgroundGapTone(tone) — the core of avoidBackgroundGap.
ts
adjustTone: (args) =>
  args.context.contrastLevel > 0
    ? backgroundGapTone(contrastTone(args.tone, 'surface', 1.5, args))
    : args.tone,

Example: a highlight pair

ts
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, …