Playground

Theme generator

Most brand colours cannot be used as a text colour, and the cheapest moment to discover that is before anyone has built anything with them.

The one from the brand guidelines. That includes the one somebody has already told you is non-negotiable.

This is a preview ramp. Your browser derives it with relative colour syntax: same hue, scaled chroma, pinned lightness. The shipped engine additionally clamps chroma per hue, escalates into Display-P3 where the screen allows, and assigns roles by measurement rather than by position. That engine is not published yet.

The measurements are real. Every Lc figure beside a step comes from the contrast service. That service is the same implementation the build runs. Where it cannot answer, the cell stays empty.

  1. 50not resolvedwhite text not measureddark text not measured
  2. 100not resolvedwhite text not measureddark text not measured
  3. 200not resolvedwhite text not measureddark text not measured
  4. 300not resolvedwhite text not measureddark text not measured
  5. 400not resolvedwhite text not measureddark text not measured
  6. 500not resolvedwhite text not measureddark text not measured
  7. 600not resolvedwhite text not measureddark text not measured
  8. 700not resolvedwhite text not measureddark text not measured
  9. 800not resolvedwhite text not measureddark text not measured
  10. 900not resolvedwhite text not measureddark text not measured
  11. 950not resolvedwhite text not measureddark text not measured

Higher |Lc| is more readable. The emboldened column is the better text colour on that step.

Copyable CSS

:root {
  --brand-50: oklch(from #2f6fd0 0.985 calc(c * 0.18) h);
  --brand-100: oklch(from #2f6fd0 0.96 calc(c * 0.3) h);
  --brand-200: oklch(from #2f6fd0 0.92 calc(c * 0.48) h);
  --brand-300: oklch(from #2f6fd0 0.86 calc(c * 0.66) h);
  --brand-400: oklch(from #2f6fd0 0.78 calc(c * 0.84) h);
  --brand-500: oklch(from #2f6fd0 0.68 calc(c * 1) h);
  --brand-600: oklch(from #2f6fd0 0.58 calc(c * 1) h);
  --brand-700: oklch(from #2f6fd0 0.48 calc(c * 0.9) h);
  --brand-800: oklch(from #2f6fd0 0.38 calc(c * 0.76) h);
  --brand-900: oklch(from #2f6fd0 0.28 calc(c * 0.6) h);
  --brand-950: oklch(from #2f6fd0 0.2 calc(c * 0.44) h);
}

What the shipped engine adds

The ramp above is derived in the browser and is deliberately simple: your hue, your chroma scaled by a factor, a pinned lightness. The colour engine that ships with opsinjs does three further things, and each of them exists because the simple version is wrong in a way that is hard to see.

Chroma clamping per hue. The sRGB gamut is not a cylinder. At a given lightness, yellow can carry far more chroma than blue, and asking for more than the gamut holds does not produce a vivid colour. It produces a silently compressed one whose lightness has moved. The engine finds the boundary for each hue and lightness rather than guessing.

Display-P3 escalation. On a wide-gamut display the ramp gains chroma without moving in lightness, so the measured contrast is unchanged and only the saturation grows. That is why the escalation is safe to apply automatically: nothing about meaning changes with gamut.

Roles by measurement, not by position. A “600” is not a text colour because it is a 600. The engine assigns the surface, line and ink roles by validating candidate pairs against the floor, which is why the shipped ramps have different role boundaries for different hues.

Where a brand colour is allowed to appear

In a health interface, less far than you would expect. The two colour axes are already spoken for: status carries the verdict, category carries identity, and both are defined by the system rather than by a brand.

A brand ramp gets the chrome. The chrome covers buttons, links, selection, navigation and the focus ring if it clears the floor. The ramp does not get a status level, and it does not get to tint a surface that reports a result. A brand blue reused as “the good colour” is how a system ends up with two vocabularies for the same idea, and the reader learns neither.

Adding a category without contaminating the status axis covers the one case where a brand does legitimately extend the system, and status palettes explains why redefining the other axis is almost always a mistake.

Preset codes and taking this further

A finished theme is distributable as an opsinjs-* preset code. It is a short, copyable string that encodes the whole configuration and can be applied with the CLI. The encoder is part of the theming tools and is not published yet, so this page hands you CSS rather than a code it cannot generate honestly. Preset codes documents the format.

When you have a theme, validate it: the conformance figures published on this site describe the shipped presets, and the moment you change a token they describe something else. The check runs in CI and is the difference between a theme that is accessible and a theme that was accessible when somebody last looked.

Individual pairs can be checked in the contrast oracle, and the shipped ramps are browsable at /colors.