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 →