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]:
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 atratio ^ (1/t).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"andbackground: "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
@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
@spec fetch(t(), stop_label()) :: {:ok, Color.SRGB.t()} | :error
Fetches the colour at a given stop label.
Arguments
paletteis aColor.Palette.ContrastScalestruct.labelis the stop label.
Returns
{:ok, color}or:errorfor 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
@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)
@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
@spec labels(t()) :: [stop_label()]
Returns the list of stop labels in the order they were configured.
Arguments
paletteis aColor.Palette.ContrastScalestruct.
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]
@spec new( Color.input(), keyword() ) :: t()
Generates a contrast-constrained tonal scale.
See the moduledoc for the algorithm.
Arguments
seedis anything accepted byColor.new/1.
Options
:stopsis 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.:guaranteeis a{ratio, apart}tuple specifying the minimum contrast ratio that any two stopsapartlabel units apart must satisfy. Default{4.5, 500}(WCAG AA body text).:backgroundis the colour against which contrast is measured. Default"white".:metricis:wcag(default) or:apca. For:apca,ratiois interpreted as an APCA Lc value.:hue_driftenables the paper's Bezold-Brücke compensation. Defaultfalse.:gamutis the gamut to map each stop into. Default:SRGB.:nameis an optional label.
Returns
- A
Color.Palette.ContrastScalestruct.
Examples
iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
iex> palette.name
"blue"
iex> palette.seed_stop in Map.keys(palette.stops)
true
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
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
Emits the palette as a W3C DTCG
color-token group, with the achieved contrast ratio for each
stop recorded in $extensions.color.achieved.
Arguments
paletteis aColor.Palette.ContrastScalestruct.
Options
:spaceis the colour space for emitted stop values. DefaultColor.Oklch.:nameoverrides the group name. Defaults to the palette's:namefield, 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"