Color.Palette.Tonal
(Color v0.11.0)
Copy Markdown
A tonal scale — N shades of one hue, from light to dark — generated from a single seed colour.
This is the algorithm behind Tailwind's blue-50 … blue-950,
Radix's 12-step scales, Open Color, and the per-role tonal
palettes inside Material Design 3.
Algorithm
Convert the seed to Oklch.
For each requested stop, compute a target lightness
Lby interpolating along a curve betweenlight_anchor(the lightness of the lightest stop) anddark_anchor(the lightness of the darkest stop). The interpolation is in stop-space — equally spaced positions in the stop list map to equally spaced lightnesses.Damp the chroma at the extremes. Tints near white and shades near black cannot carry as much chroma as a mid-tone of the same hue without falling out of the sRGB gamut. The library multiplies the seed's chroma by
sin(π · L)to taper it smoothly toward zero atL = 0andL = 1.Optionally apply hue drift. When
hue_drift: true, the hue rotates slightly toward yellow (~90°) at the light end and toward blue (~270°) at the dark end. This matches how human vision perceives lightness — a phenomenon known as the Hunt effect — and gives the scale a more natural feel.Cap to a fraction of the gamut envelope. When
:chroma_ceilingis below1.0, cap each stop's chroma atceiling × max_chroma(L, H, gamut). This produces a more muted ramp that sits strictly inside the gamut boundary instead of hugging it. At the default1.0this step is a no-op.Snap to seed. Find the generated stop whose lightness is closest to the seed's lightness and replace it with the seed itself. The
:seed_stopfield on the resulting struct records which stop received the seed. Note that when:chroma_ceilingis below1.0and the seed sits near the gamut boundary, the seed will be visibly more saturated than its capped neighbours — pick a wider:gamutinstead if you want a smoother ramp without muting the seed.Gamut-map each stop into the requested working space (default
:SRGB) using the CSS Color 4 Oklch binary-search algorithm provided byColor.Gamut.to_gamut/3.
Stops
By default the stops are Tailwind's [50, 100, 200, …, 950],
but any list of integer or atom labels may be supplied. The
algorithm cares only about position in the list, not the label
values themselves — ["lightest", "light", "mid", "dark", "darkest"] works just as well.
Example
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> Map.fetch!(palette.stops, 50) |> Color.to_hex()
"#f5f9ff"
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> Map.fetch!(palette.stops, 950) |> Color.to_hex()
"#000827"
Summary
Functions
Fetches the colour at a given stop label.
Returns a detailed per-stop gamut report.
Returns true when every stop in the palette is inside the
given RGB working space.
Returns the list of stop labels in generation order.
Generates a tonal scale from a seed colour.
Emits the palette as a block of CSS custom properties, one per stop, keyed by the palette name.
Emits the palette as a Tailwind CSS v4 @theme block —
CSS-native theme variables using the --color-* namespace
that Tailwind v4 expects.
Emits the palette as a W3C Design Tokens Community Group color-token group.
Types
@type t() :: %Color.Palette.Tonal{ 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.Tonalstruct.labelis the stop label to look up.
Returns
{:ok, color}on success,:errorif the label is unknown.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> {:ok, _color} = Color.Palette.Tonal.fetch(palette, 500)
iex> Color.Palette.Tonal.fetch(palette, :missing)
:error
@spec gamut_report(t(), Color.Types.working_space()) :: map()
Returns a detailed per-stop gamut report.
Useful for actionable CI output: which specific stops fell outside the target gamut and need attention.
Arguments
paletteis aColor.Palette.Tonalstruct.working_spaceis an RGB working-space atom. Defaults to:SRGB.
Returns
- A map with:
:working_space— the space the check was performed against.:in_gamut?—trueif every stop is inside, elsefalse.:stops— list of%{label, color, in_gamut?}maps, one per stop, in label order.:out_of_gamut— list of the same map shape, filtered to stops that are outside the target space.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> report = Color.Palette.Tonal.gamut_report(palette, :SRGB)
iex> report.in_gamut?
true
iex> report.out_of_gamut
[]
@spec in_gamut?(t(), Color.Types.working_space()) :: boolean()
Returns true when every stop in the palette is inside the
given RGB working space.
Convenient for CI checks — fail the build if a palette generated against one working space slips outside another's gamut.
Arguments
paletteis aColor.Palette.Tonalstruct.working_spaceis an RGB working-space atom. Defaults to:SRGB.
Returns
- A boolean.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> Color.Palette.Tonal.in_gamut?(palette, :SRGB)
true
@spec labels(t()) :: [stop_label()]
Returns the list of stop labels in generation order.
Arguments
paletteis aColor.Palette.Tonalstruct.
Returns
- A list of stop labels.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> Color.Palette.Tonal.labels(palette)
[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
@spec new( Color.input(), keyword() ) :: t()
Generates a tonal scale from a seed colour.
See the moduledoc for a description of the algorithm and
Color.Palette.tonal/2 for option details.
Arguments
seedis anything accepted byColor.new/1.
Options
Returns
- A
Color.Palette.Tonalstruct.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "brand")
iex> palette.name
"brand"
iex> palette.seed_stop in [400, 500]
true
Emits the palette as a block of CSS custom properties, one per stop, keyed by the palette name.
Output is a plain binary, ready to write to a stylesheet.
Arguments
paletteis aColor.Palette.Tonalstruct.
Options
:nameoverrides the property prefix. Defaults to the palette's:namefield, or"color"if unset.:selectoris the CSS selector the properties are declared on. Default":root".
Returns
- A binary containing a single CSS rule.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "blue")
iex> css = Color.Palette.Tonal.to_css(palette)
iex> String.contains?(css, "--blue-500:")
true
iex> palette = Color.Palette.Tonal.new("#3b82f6")
iex> Color.Palette.Tonal.to_css(palette, name: "brand", selector: "[data-theme]")
...> |> String.starts_with?("[data-theme] {")
true
Emits the palette as a Tailwind CSS v4 @theme block —
CSS-native theme variables using the --color-* namespace
that Tailwind v4 expects.
Arguments
paletteis aColor.Palette.Tonalstruct.
Options
:nameoverrides the key name. Defaults to the palette's:namefield, or"color"if unset.
Returns
- A binary containing a
@themeblock ready to paste into a Tailwind v4 CSS file.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "blue")
iex> config = Color.Palette.Tonal.to_tailwind(palette)
iex> String.contains?(config, "--color-blue-500:")
true
iex> String.contains?(config, "@theme")
true
Emits the palette as a W3C Design Tokens Community Group color-token group.
The result is a nested map ready for :json.encode/1. Each
stop becomes a DTCG color token keyed by its label.
Arguments
paletteis aColor.Palette.Tonalstruct.
Options
:spaceis the colour space to emit components in. Any module accepted byColor.convert/2. DefaultColor.Oklch.:nameoverrides the group name. Defaults to the palette's:namefield, or"color"if unset.
Returns
- A map shaped as
%{"<name>" => %{"<stop>" => token, ...}}.
Examples
iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "blue")
iex> tokens = Color.Palette.Tonal.to_tokens(palette)
iex> tokens["blue"]["500"]["$type"]
"color"