Skip to content

CSS Gradient Generator — Swift source

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

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

// =============================================================================
//  css-gradient-generator.swift — CosmoDev polyglot showcase port of the
//  `css-gradient-generator` tool
//  -----------------------------------------------------------------------------
//  Language : Swift (5.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 struct. Deterministic and side-effect free;
//  invalid input degrades gracefully (unknown colors → solid black, too few
//  stops → black/white default ramp) rather than trapping.
// =============================================================================

import Foundation

/// CSS gradient kinds we know how to render.
enum GradientType {
    case linear, radial, conic
}

/// One color anchor on the gradient ramp. Position is a percentage 0..100.
struct GradientStop {
    var color: String
    var position: Double
}

/// Full input to `CssGradient.buildGradient`. `radialShape` is only meaningful
/// for `.radial`; nil falls back to "circle" (mirroring the TypeScript
/// `?? 'circle'` — an explicit "" passes through unchanged).
struct GradientConfig {
    var type: GradientType
    var angle: Double
    var stops: [GradientStop]
    var radialShape: String?
}

/// Outcome of `CssGradient.parseColor`: an ok flag plus a human message (nil when ok).
struct ColorResult: Equatable {
    var ok: Bool
    var error: String?
}

enum 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 static let namedColors: Set<String> = [
        "transparent", "black", "white", "red", "green", "blue", "yellow", "orange",
        "purple", "pink", "gray", "grey", "brown", "cyan", "magenta", "none", "currentcolor",
    ]

    /// True if a character 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 static func isHexByte(_ ch: Character) -> Bool {
        ("0"..."9").contains(ch) || ("a"..."f").contains(ch)
    }

    /// 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 — NSRegularExpression is overkill for a shape test.
    private static func isHexColor(_ c: String) -> Bool {
        let validLen = c.count == 4 || c.count == 7 || c.count == 9 // '#' + {3,6,8} digits
        return validLen && c.first == "#" && c.dropFirst().allSatisfy(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 static func isFunctionalColor(_ c: String, openers: [String]) -> Bool {
        for opener in openers {
            if c.hasPrefix(opener) {
                guard c.hasSuffix(")") else { return false }
                let body = c.dropFirst(opener.count).dropLast()
                return !body.isEmpty && !body.contains(")")
            }
        }
        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 (Foundation's
    /// whitespace set ≈ JS trim) and lowercased before testing.
    static func parseColor(_ color: String) -> ColorResult {
        let c = color.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        if c.isEmpty { return ColorResult(ok: false, error: "empty color") }
        if namedColors.contains(c) { return ColorResult(ok: true, error: nil) }
        if isHexColor(c) { return ColorResult(ok: true, error: nil) }
        if isFunctionalColor(c, openers: ["rgba(", "rgb("])
            || isFunctionalColor(c, openers: ["hsla(", "hsl("]) {
            return ColorResult(ok: true, error: nil)
        }
        return 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 static func normalizeColor(_ color: String) -> String {
        parseColor(color).ok ? color.trimmingCharacters(in: .whitespacesAndNewlines) : "#000000"
    }

    /// Round the way JavaScript's Math.round does (half toward +infinity).
    /// Swift's `rounded()` rounds half away from zero, which would disagree on
    /// .5 values; flooring (x + 0.5) matches Math.round for all non-negative
    /// inputs — the gradient-position domain.
    private static func roundLikeJs(_ x: Double) -> Int {
        Int((x + 0.5).rounded(.down))
    }

    /// Render a double the way JavaScript's template literal does.
    ///
    /// Swift's default Double description is the shortest round-tripping
    /// decimal; we only strip the trailing ".0" whole doubles carry, so 90.0
    /// renders as "90", matching String(90).
    private static func formatNumber(_ x: Double) -> String {
        let s = String(x)
        return s.hasSuffix(".0") ? String(s.dropLast(2)) : s
    }

    /// Render a complete CSS gradient string.
    ///
    /// Stops are sorted ascending by position. `sorted` is not guaranteed
    /// stable, so equal positions tie-break on original index — 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.
    static func buildGradient(_ config: GradientConfig) -> String {
        let stops: [GradientStop]
        if config.stops.count < 2 {
            stops = [
                GradientStop(color: "#000000", position: 0),
                GradientStop(color: "#ffffff", position: 100),
            ]
        } else {
            stops = config.stops.enumerated()
                .sorted { lhs, rhs in
                    lhs.element.position == rhs.element.position
                        ? lhs.offset < rhs.offset
                        : lhs.element.position < rhs.element.position
                }
                .map(\.element)
        }

        let stopsStr = stops
            .map { "\(normalizeColor($0.color)) \(roundLikeJs($0.position))%" }
            .joined(separator: ", ")

        let angle = formatNumber(config.angle)

        switch config.type {
        case .linear:
            return "linear-gradient(\(angle)deg, \(stopsStr))"
        case .radial:
            // `?? 'circle'`: nil → "circle"; non-nil → verbatim (even if empty).
            let shape = config.radialShape ?? "circle"
            return "radial-gradient(\(shape), \(stopsStr))"
        case .conic:
            return "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 →