Skip to content

CSS Gradient Generator — Kotlin source

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

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

// =============================================================================
//  css-gradient-generator.kt — CosmoDev polyglot showcase port of the
//  `css-gradient-generator` tool
//  -----------------------------------------------------------------------------
//  Language : Kotlin (1.9, standard library only)
//  Source:   ported from src/lib/cssGradient.ts (the canonical, live TypeScript
//             lib); mirrors src/tool-sources/css-gradient-generator/{python.py,rust.rs}
//  License  : display source — part of CosmoDev's polyglot tool pages
//             (dev.cosmolabs.org). Shown verbatim alongside the JS/TS/Go/Rust/
//             Python ports and the other language ports.
//  -----------------------------------------------------------------------------
//  Pure CSS-gradient builder. Build linear / radial / conic CSS gradient
//  strings from a small config. Deterministic and side-effect free; invalid
//  input degrades gracefully (unknown colors → solid black, too few stops →
//  black/white default ramp) rather than throwing.
// =============================================================================

package com.cosmolabs.cosmodev.cssgradient

import kotlin.math.floor

/** CSS gradient kinds we know how to render. */
enum class GradientType { LINEAR, RADIAL, CONIC }

/** One color anchor on the gradient ramp. Position is a percentage 0..100. */
data class GradientStop(val color: String, val position: Double)

/**
 * Full input to [CssGradient.buildGradient]. [radialShape] is only meaningful
 * for [GradientType.RADIAL]; `null` falls back to "circle" (mirroring the
 * TypeScript `?: 'circle'` — an explicit `""` passes through unchanged).
 */
data class GradientConfig(
    val type: GradientType,
    val angle: Double,
    val stops: List<GradientStop>,
    val radialShape: String? = null,
)

/** Outcome of [CssGradient.parseColor]: an ok flag plus a human message (null when ok). */
data class ColorResult(val ok: Boolean, val error: String? = null)

object CssGradient {

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

    /** True if a char is a lowercase ASCII hex digit ('0'-'9' or 'a'-'f'). Only
     * the lowercase form is accepted because [parseColor] lowercases its input
     * before testing, exactly like the TS regex `[0-9a-f]`. */
    private fun isHexByte(ch: Char): Boolean = ch in '0'..'9' || ch in 'a'..'f'

    /** Validates a hex color by shape: '#' followed by exactly 3, 6, or 8
     * lowercase hex digits. This collapses the two TS regexes
     * (`#[0-9a-f]{3}([0-9a-f]{3})?` and `#[0-9a-f]{8}`) into one structural check. */
    private fun isHexColor(c: String): Boolean {
        val n = c.length
        if (n != 4 && n != 7 && n != 9) return false // '#' + {3,6,8} digits
        if (c.first() != '#') return false
        return c.drop(1).all(::isHexByte)
    }

    /** Validates a functional color form "name(...)": the string must start
     * with one of [openers] (e.g. "rgba(", "rgb("), end with ')', and have a
     * nonempty body containing no ')'. Mirrors the TS `^rgba?\([^)]+\)$` /
     * `^hsla?\([^)]+\)$`. Longer openers must come first so "rgba(" is tried
     * before "rgb(". */
    private fun isFunctionalColor(c: String, vararg openers: String): Boolean {
        for (opener in openers) {
            if (c.startsWith(opener)) {
                if (!c.endsWith(")")) return false
                val body = c.substring(opener.length, c.length - 1)
                return body.isNotEmpty() && ')' !in body
            }
        }
        return false
    }

    /** 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
     * before testing.
     */
    fun parseColor(color: String): ColorResult {
        val c = color.trim().lowercase()
        return when {
            c.isEmpty() -> ColorResult(ok = false, error = "empty color")
            c in NAMED_COLORS -> ColorResult(ok = true)
            isHexColor(c) -> ColorResult(ok = true)
            isFunctionalColor(c, "rgba(", "rgb(") || isFunctionalColor(c, "hsla(", "hsl(") ->
                ColorResult(ok = true)
            else -> ColorResult(ok = false, error = "invalid color: $color")
        }
    }

    /** Coerce a possibly-invalid color to a safe value: valid → the trimmed
     * original (casing preserved), invalid → solid black. Guarantees the
     * gradient always has a usable color value. */
    private fun normalizeColor(color: String): String =
        if (parseColor(color).ok) color.trim() else "#000000"

    /** Render a double the way JavaScript's template literal does.
     *
     * Kotlin's (Java's) Double.toString keeps enough digits to round-trip and
     * prints whole doubles with a trailing ".0"; we strip it so 90.0 renders
     * as "90", matching String(90). */
    private fun formatNumber(x: Double): String {
        val s = x.toString()
        return if (s.endsWith(".0")) s.dropLast(2) else s
    }

    /** Render a complete CSS gradient string.
     *
     * Stops are sorted ascending by position (sortedBy is stable — TimSort
     * under the hood — matching modern JavaScript's Array.sort; a NaN position
     * is out of domain). Fewer than two stops collapse to a black → white
     * default ramp so the output is always renderable. Positions round via
     * floor(x + 0.5), which agrees with Math.round on the non-negative 0..100
     * position domain. */
    fun buildGradient(config: GradientConfig): String {
        var stops = config.stops.sortedBy { it.position }

        if (stops.size < 2) {
            stops = listOf(
                GradientStop(color = "#000000", position = 0.0),
                GradientStop(color = "#ffffff", position = 100.0),
            )
        }

        val stopsStr = stops.joinToString(", ") {
            "${normalizeColor(it.color)} ${floor(it.position + 0.5).toInt()}%"
        }

        val angle = formatNumber(config.angle)

        return when (config.type) {
            GradientType.LINEAR -> "linear-gradient(${angle}deg, $stopsStr)"
            // `?:`: null → "circle"; non-null → verbatim (even if empty).
            GradientType.RADIAL -> "radial-gradient(${config.radialShape ?: "circle"}, $stopsStr)"
            GradientType.CONIC -> "conic-gradient(from ${angle}deg, $stopsStr)"
        }
    }
}

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 →