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-50blue-950, Radix's 12-step scales, Open Color, and the per-role tonal palettes inside Material Design 3.

Algorithm

  1. Convert the seed to Oklch.

  2. For each requested stop, compute a target lightness L by interpolating along a curve between light_anchor (the lightness of the lightest stop) and dark_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.

  3. 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 at L = 0 and L = 1.

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

  5. Cap to a fraction of the gamut envelope. When :chroma_ceiling is below 1.0, cap each stop's chroma at ceiling × max_chroma(L, H, gamut). This produces a more muted ramp that sits strictly inside the gamut boundary instead of hugging it. At the default 1.0 this step is a no-op.

  6. 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_stop field on the resulting struct records which stop received the seed. Note that when :chroma_ceiling is below 1.0 and the seed sits near the gamut boundary, the seed will be visibly more saturated than its capped neighbours — pick a wider :gamut instead if you want a smoother ramp without muting the seed.

  7. Gamut-map each stop into the requested working space (default :SRGB) using the CSS Color 4 Oklch binary-search algorithm provided by Color.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

stop_label()

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

t()

@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

fetch(tonal, label)

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

Fetches the colour at a given stop label.

Arguments

Returns

  • {:ok, color} on success, :error if 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

gamut_report(palette, working_space \\ :SRGB)

@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

  • palette is a Color.Palette.Tonal struct.

  • working_space is an RGB working-space atom. Defaults to :SRGB.

Returns

  • A map with:
    • :working_space — the space the check was performed against.

    • :in_gamut?true if every stop is inside, else false.

    • :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
[]

in_gamut?(tonal, 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.

Convenient for CI checks — fail the build if a palette generated against one working space slips outside another's gamut.

Arguments

  • palette is a Color.Palette.Tonal struct.

  • working_space is 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

labels(tonal)

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

Returns the list of stop labels in generation order.

Arguments

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]

new(seed, options \\ [])

@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

Options

See Color.Palette.tonal/2.

Returns

Examples

iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "brand")
iex> palette.name
"brand"
iex> palette.seed_stop in [400, 500]
true

to_css(palette, options \\ [])

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

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

Options

  • :name overrides the property prefix. Defaults to the palette's :name field, or "color" if unset.

  • :selector is 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

to_tailwind(palette, options \\ [])

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

Emits the palette as a Tailwind CSS v4 @theme block — CSS-native theme variables using the --color-* namespace that Tailwind v4 expects.

Arguments

Options

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

Returns

  • A binary containing a @theme block 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

to_tokens(palette, options \\ [])

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

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

Options

  • :space is the colour space to emit components in. Any module accepted by Color.convert/2. Default Color.Oklch.

  • :name overrides the group name. Defaults to the palette's :name field, 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"