Skip to content

Color Palette Generator — Python source

Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""palette-generator — polyglot showcase port (Python).

Pure color-theory helpers for the Color Palette Generator. No I/O, no external
dependencies — standard library only. All inputs are clamped; the functions
never raise on bad input (they fall back to black).

Ported from src/lib/colorPalette.ts (TypeScript, the canonical implementation).
This is display source — part of CosmoDev's polyglot tool pages
(dev.cosmolabs.org), where each tool's pure logic is shown side-by-side in six
languages.
"""

from __future__ import annotations

import math
import re
from typing import Literal

# Scheme identifiers selecting which color-harmony rule generate_palette applies.
# This is the runtime analog of the TypeScript ``Scheme`` string-union type.
Scheme = Literal[
    "complement",
    "split-complement",
    "analogous",
    "triadic",
    "tetradic",
    "monochromatic",
]

_HEX3 = re.compile(r"^[0-9a-fA-F]{3}$")
_HEX6 = re.compile(r"^[0-9a-fA-F]{6}$")


def hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
    """Parse any reasonable hex string (#rgb / #rrggbb, with or without a leading #)
    into an (r, g, b) byte triple. Unparseable input falls back to black."""
    h = re.sub(r"^#", "", str(hex_str).strip())
    if _HEX3.match(h):
        # Expand CSS shorthand: each digit doubles (#abc -> #aabbcc).
        h = "".join(c + c for c in h)
    if not _HEX6.match(h):
        return (0, 0, 0)
    return (
        int(h[0:2], 16),
        int(h[2:4], 16),
        int(h[4:6], 16),
    )


def _round_half_up(n: float) -> int:
    """Round half up (toward +Inf) to match TypeScript's ``Math.round`` on the
    non-negative color values these helpers produce. Python's built-in
    ``round()`` uses banker's rounding (half to even), which would diverge from
    the canonical TypeScript output at exact .5 boundaries."""
    return math.floor(n + 0.5)


def _clamp_byte(n: float) -> int:
    """Round, then clamp to a byte in [0, 255]."""
    return max(0, min(255, _round_half_up(n)))


def _rgb_to_hex(r: float, g: float, b: float) -> str:
    """Pack three float channels into a lowercase #rrggbb string."""
    return f"#{_clamp_byte(r):02x}{_clamp_byte(g):02x}{_clamp_byte(b):02x}"


def hex_to_hsl(hex_str: str) -> tuple[float, float, float]:
    """Convert hex → HSL. h ∈ [0, 360), s/l ∈ [0, 100]. Achromatic colors (gray,
    white, black — no dominant hue) collapse to h = 0, s = 0."""
    r8, g8, b8 = hex_to_rgb(hex_str)
    r = r8 / 255
    g = g8 / 255
    b = b8 / 255
    mx = max(r, g, b)
    mn = min(r, g, b)
    l = (mx + mn) / 2
    h = 0.0
    s = 0.0
    if mx != mn:
        d = mx - mn
        s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
        # Which channel is max determines the hue sextant.
        if mx == r:
            h = (g - b) / d + (6 if g < b else 0)
        elif mx == g:
            h = (b - r) / d + 2
        else:
            h = (r - g) / d + 4
        h /= 6
    return (h * 360, s * 100, l * 100)


def hsl_to_hex(h: float, s: float, l: float) -> str:
    """Convert HSL → hex. h wraps modulo 360 (so h + 150 etc. stay in range);
    s and l clamp to [0, 100]."""
    # Python's % is a true modulo (always non-negative for a positive divisor),
    # so the double-mod wrap yields the same [0, 360) result as the TS source
    # even though TS's % is a remainder.
    H = ((h % 360) + 360) % 360
    S = max(0.0, min(100.0, s)) / 100
    L = max(0.0, min(100.0, l)) / 100
    c = (1 - abs(2 * L - 1)) * S
    x = c * (1 - abs((H / 60) % 2 - 1))
    m = L - c / 2
    if H < 60:
        r, g, b = c, x, 0.0
    elif H < 120:
        r, g, b = x, c, 0.0
    elif H < 180:
        r, g, b = 0.0, c, x
    elif H < 240:
        r, g, b = 0.0, x, c
    elif H < 300:
        r, g, b = x, 0.0, c
    else:
        r, g, b = c, 0.0, x
    return _rgb_to_hex((r + m) * 255, (g + m) * 255, (b + m) * 255)


def generate_palette(base_hex: str, scheme: Scheme, count: int = 5) -> list[str]:
    """Generate a harmonious palette from a base color.

    Counts: complement = 2, split-complement = 3, analogous = 3, triadic = 3,
    tetradic = 4. ``count`` is honored by monochromatic (default 5): the base
    hue and saturation are held while lightness spreads across ``count`` steps.
    """
    h, s, l = hex_to_hsl(base_hex)
    base = hsl_to_hex(h, s, l)

    def rot(deg: float) -> str:
        # Rotate the hue by deg degrees, holding saturation and lightness.
        return hsl_to_hex(h + deg, s, l)

    if scheme == "complement":
        return [base, rot(180)]
    if scheme == "split-complement":
        return [base, rot(150), rot(210)]
    if scheme == "analogous":
        return [rot(-30), base, rot(30)]
    if scheme == "triadic":
        return [base, rot(120), rot(240)]
    if scheme == "tetradic":
        return [base, rot(90), rot(180), rot(270)]
    if scheme == "monochromatic":
        n = max(1, math.floor(count))
        # Pin the lightness window to [10, 90] so swatches never fully wash out
        # or go black, regardless of the base color's own lightness.
        lo = max(10, l - 32)
        hi = min(90, l + 32)
        out: list[str] = []
        for i in range(n):
            ll = l if n == 1 else lo + (hi - lo) * i / (n - 1)
            out.append(hsl_to_hex(h, s, ll))
        return out
    return [base]


def shades(base_hex: str, n: int) -> list[str]:
    """n shades — the base color mixed progressively toward black (RGB lerp).
    i runs 1..n so the base itself is never returned, only intermediate steps."""
    r, g, b = hex_to_rgb(base_hex)
    steps = max(1, math.floor(n))
    out: list[str] = []
    for i in range(1, steps + 1):
        f = i / (steps + 1)
        out.append(_rgb_to_hex(r * (1 - f), g * (1 - f), b * (1 - f)))
    return out


def tints(base_hex: str, n: int) -> list[str]:
    """n tints — the base color mixed progressively toward white (RGB lerp).
    i runs 1..n so the base itself is never returned."""
    r, g, b = hex_to_rgb(base_hex)
    steps = max(1, math.floor(n))
    out: list[str] = []
    for i in range(1, steps + 1):
        f = i / (steps + 1)
        out.append(
            _rgb_to_hex(
                r + (255 - r) * f,
                g + (255 - g) * f,
                b + (255 - b) * f,
            )
        )
    return out

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →