Color.Palette.ContrastScale (Color v0.11.0)

Copy Markdown

A contrast-constrained tonal scale — the hybrid algorithm described by Matt Ström-Awn in Generating colour palettes with math.

The scale is generated so that any two stops whose labels differ by at least apart are guaranteed to satisfy a minimum contrast ratio against each other. For example, with the default guarantee: {4.5, 500}, stops 50 and 600 — or any other pair ≥ 500 apart — will contrast ≥ 4.5 : 1 against each other against the given background.

Unlike Color.Palette.Tonal, which produces visually-even lightness steps with no contrast guarantee, and Color.Palette.Contrast, which places stops at specific contrast targets with no between-stop invariant, this algorithm makes pairwise contrast a structural property of the scale.

Algorithm

Let t = apart / (max_label − min_label) — the fraction of the scale that the apart distance spans. For each stop with normalised position p ∈ [0, 1]:

  1. Compute the stop's target contrast against the background: C(p) = ratio ^ (p / t). This places the lightest stop at contrast 1 (equal to background) and the darkest at ratio ^ (1/t).

  2. Binary-search Oklch lightness for a colour that achieves C(p) against the background, holding the seed's hue and chroma approximately constant.

The pairwise invariant falls out of this: for any two stops i, j with |p_j − p_i| ≥ t, contrast(i, j) = C_j / C_i ≥ ratio.

Hue drift (hue_drift: true) applies the paper's Bezold-Brücke compensation: H(p) = H_base + 5 · (1 − p).

When to reach for this

  • You want a Tailwind-style numeric scale and you want pairwise contrast guarantees built in.

  • You're building an accessible design system and you don't want to audit individual pairs after the fact.

  • You want light and dark modes to follow the same contrast rules by construction — generate the same seed against background: "white" and background: "black" and the invariant holds on both.

For component states tied to specific ratios (resting 3 : 1, focus 4.5 : 1, disabled 1.3 : 1), Color.Palette.Contrast is still the right tool. For purely visual scales with no accessibility requirement, Color.Palette.Tonal produces smoother-looking results.

Example

iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
iex> Map.keys(palette.stops) |> Enum.sort()
[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]

iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
iex> {ratio, apart} = palette.guarantee
iex> {ratio, apart}
{4.5, 500}

Summary

Functions

Fetches the colour at a given stop label.

Returns a detailed per-stop gamut report. See Color.Palette.Tonal.gamut_report/2 for the shape.

Returns true when every stop in the palette is inside the given RGB working space. See Color.Palette.Tonal.in_gamut?/2 for details.

Returns the list of stop labels in the order they were configured.

Generates a contrast-constrained tonal scale.

Emits the palette as a block of CSS custom properties. See Color.Palette.Tonal.to_css/2 for option details.

Emits the palette as a Tailwind CSS v4 @theme block. See Color.Palette.Tonal.to_tailwind/2 for option details.

Emits the palette as a W3C DTCG color-token group, with the achieved contrast ratio for each stop recorded in $extensions.color.achieved.

Types

guarantee()

@type guarantee() :: {number(), number()}

stop_label()

@type stop_label() :: integer() | atom() | binary()

t()

@type t() :: %Color.Palette.ContrastScale{
  achieved: %{required(stop_label()) => number()},
  background: Color.SRGB.t(),
  guarantee: guarantee(),
  metric: :wcag | :apca,
  name: binary() | nil,
  options: keyword(),
  seed: Color.SRGB.t(),
  seed_stop: stop_label(),
  stops: %{required(stop_label()) => Color.SRGB.t()}
}

Functions

fetch(contrast_scale, label)

@spec fetch(t(), stop_label()) :: {:ok, Color.SRGB.t()} | :error

Fetches the colour at a given stop label.

Arguments

Returns

  • {:ok, color} or :error for unknown labels.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
iex> {:ok, _} = Color.Palette.ContrastScale.fetch(palette, 500)
iex> Color.Palette.ContrastScale.fetch(palette, :missing)
:error

gamut_report(palette, working_space \\ :SRGB)

@spec gamut_report(t(), Color.Types.working_space()) :: map()

Returns a detailed per-stop gamut report. See Color.Palette.Tonal.gamut_report/2 for the shape.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
iex> %{in_gamut?: true} = Color.Palette.ContrastScale.gamut_report(palette, :SRGB)

in_gamut?(contrast_scale, working_space \\ :SRGB)

@spec in_gamut?(t(), Color.Types.working_space()) :: boolean()

Returns true when every stop in the palette is inside the given RGB working space. See Color.Palette.Tonal.in_gamut?/2 for details.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
iex> Color.Palette.ContrastScale.in_gamut?(palette, :SRGB)
true

labels(contrast_scale)

@spec labels(t()) :: [stop_label()]

Returns the list of stop labels in the order they were configured.

Arguments

Returns

  • A list of stop labels.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
iex> Color.Palette.ContrastScale.labels(palette)
[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]

new(seed, options \\ [])

@spec new(
  Color.input(),
  keyword()
) :: t()

Generates a contrast-constrained tonal scale.

See the moduledoc for the algorithm.

Arguments

Options

  • :stops is the list of numeric stop labels. Default [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]. The labels must be numeric — the contrast invariant is defined in terms of label distance.

  • :guarantee is a {ratio, apart} tuple specifying the minimum contrast ratio that any two stops apart label units apart must satisfy. Default {4.5, 500} (WCAG AA body text).

  • :background is the colour against which contrast is measured. Default "white".

  • :metric is :wcag (default) or :apca. For :apca, ratio is interpreted as an APCA Lc value.

  • :hue_drift enables the paper's Bezold-Brücke compensation. Default false.

  • :gamut is the gamut to map each stop into. Default :SRGB.

  • :name is an optional label.

Returns

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
iex> palette.name
"blue"
iex> palette.seed_stop in Map.keys(palette.stops)
true

to_css(palette, options \\ [])

@spec to_css(
  t(),
  keyword()
) :: binary()

Emits the palette as a block of CSS custom properties. See Color.Palette.Tonal.to_css/2 for option details.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
iex> css = Color.Palette.ContrastScale.to_css(palette)
iex> String.contains?(css, "--blue-500:")
true

to_tailwind(palette, options \\ [])

@spec to_tailwind(
  t(),
  keyword()
) :: binary()

Emits the palette as a Tailwind CSS v4 @theme block. See Color.Palette.Tonal.to_tailwind/2 for option details.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
iex> tw = Color.Palette.ContrastScale.to_tailwind(palette)
iex> String.contains?(tw, "--color-blue-500:")
true

to_tokens(palette, options \\ [])

@spec to_tokens(
  t(),
  keyword()
) :: map()

Emits the palette as a W3C DTCG color-token group, with the achieved contrast ratio for each stop recorded in $extensions.color.achieved.

Arguments

Options

  • :space is the colour space for emitted stop values. Default Color.Oklch.

  • :name overrides the group name. Defaults to the palette's :name field, or "contrast_scale" if unset.

Returns

  • A map shaped as %{"<name>" => %{"<label>" => token, ...}}.

Examples

iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
iex> tokens = Color.Palette.ContrastScale.to_tokens(palette)
iex> tokens["blue"]["500"]["$type"]
"color"