Skip to content

CSS Gradient Generator — Python source

Build linear, radial, and conic CSS gradients with multiple color stops and positions. Live preview and copy-ready CSS.

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

"""Pure CSS-gradient builder — Python polyglot showcase port.

Language:    Python 3 (standard library only)
Origin:      CosmoDev polyglot showcase — port of the css-gradient-generator tool
Ported from: src/lib/cssGradient.ts (the canonical TypeScript implementation)

Purpose:     Build linear / radial / conic CSS gradient strings from a small
             config. Deterministic and side-effect free — never raises.

Display source — part of CosmoDev's polyglot tool pages.
"""

from __future__ import annotations

import math
import re
from dataclasses import dataclass
from typing import Literal, Optional

GradientType = Literal["linear", "radial", "conic"]

# Named CSS colors this tool accepts. The full spec defines ~148, but we accept
# only the common, unambiguous set so output stays predictable (mirrors the
# TypeScript allow-list).
_NAMED_COLORS = frozenset({
    "transparent", "black", "white", "red", "green", "blue", "yellow",
    "orange", "purple", "pink", "gray", "grey", "brown", "cyan", "magenta",
    "none", "currentcolor",
})

# Regexes mirror the TypeScript source exactly.
_HEX_3_OR_6 = re.compile(r"^#[0-9a-f]{3}([0-9a-f]{3})?$")
_HEX_8 = re.compile(r"^#[0-9a-f]{8}$")
_RGB_FUNC = re.compile(r"^rgba?\([^)]+\)$")
_HSL_FUNC = re.compile(r"^hsla?\([^)]+\)$")


@dataclass
class GradientStop:
    """One color anchor on the gradient ramp. Position is a percentage 0..100."""

    color: str
    position: float


@dataclass
class GradientConfig:
    """Full input to build_gradient.

    `radial_shape` is only meaningful for type "radial"; None falls back to
    "circle" (mirroring TS `?? 'circle'` — an explicit empty string passes
    through unchanged).
    """

    type: GradientType
    angle: float
    stops: list[GradientStop]
    radial_shape: Optional[str] = None


@dataclass
class ColorResult:
    """Outcome of parse_color: an ok flag plus a human message (None when ok)."""

    ok: bool
    error: Optional[str] = None


def parse_color(color: str) -> ColorResult:
    """Validate a CSS color string.

    Accepts named colors, #RGB / #RRGGBB / #RRGGBBAA hex, and rgb()/rgba()/
    hsl()/hsla() functional forms. The input is trimmed and lowercased first.
    """
    c = (color or "").strip().lower()
    if not c:
        return ColorResult(ok=False, error="empty color")
    if c in _NAMED_COLORS:
        return ColorResult(ok=True)
    if _HEX_3_OR_6.match(c) or _HEX_8.match(c):
        return ColorResult(ok=True)
    if _RGB_FUNC.match(c) or _HSL_FUNC.match(c):
        return ColorResult(ok=True)
    return ColorResult(ok=False, error=f"invalid color: {color}")


def _normalize_color(color: str) -> str:
    """Coerce a possibly-invalid color to a safe value: invalid → solid black.

    Original casing is preserved — we only trim, matching the TS source.
    """
    return color.strip() if parse_color(color).ok else "#000000"


def _round_like_js(x: float) -> int:
    """Round the way JavaScript's Math.round does (half toward +infinity).

    Python's built-in round() uses banker's rounding (half to even), which
    would disagree with Math.round on .5 values. math.floor(x + 0.5) matches
    Math.round for all non-negative inputs — the gradient-position domain.
    """
    return math.floor(x + 0.5)


def _format_number(x: float) -> str:
    """Render a float the way JavaScript's template literal does.

    JS uses the shortest round-tripping decimal; Python's str/repr for floats
    does too (since 3.1). We drop the trailing ".0" Python adds to whole
    floats so 90.0 renders as "90", matching String(90).
    """
    s = repr(float(x))
    return s[:-2] if s.endswith(".0") else s


def build_gradient(config: GradientConfig) -> str:
    """Render a complete CSS gradient string.

    Stops are sorted ascending by position (stable — sorted() is stable,
    matching modern JavaScript's Array.sort). Fewer than two stops collapse to
    a black → white default ramp so the output is always renderable.
    """
    stops = sorted(config.stops, key=lambda s: s.position)

    if len(stops) < 2:
        stops = [
            GradientStop(color="#000000", position=0),
            GradientStop(color="#ffffff", position=100),
        ]

    stops_str = ", ".join(
        f"{_normalize_color(s.color)} {_round_like_js(s.position)}%" for s in stops
    )

    angle = _format_number(config.angle)

    if config.type == "linear":
        return f"linear-gradient({angle}deg, {stops_str})"
    if config.type == "radial":
        # `?? 'circle'`: only None falls back to "circle"; an explicit empty
        # string passes through (and would produce a degenerate gradient).
        shape = config.radial_shape if config.radial_shape is not None else "circle"
        return f"radial-gradient({shape}, {stops_str})"
    if config.type == "conic":
        return f"conic-gradient(from {angle}deg, {stops_str})"
    # Unknown type — mirrors the TypeScript, which falls off the switch and
    # implicitly returns None.
    return ""

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 →