Skip to content

CSS Animation Playground — Swift source

Design and test CSS animations live - preview easing curves, durations, and keyframes, then copy the exact CSS.

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

// css-animation-playground — Swift port (CosmoDev polyglot showcase).
//
// CSS cubic-bezier easing utilities: given an animation progress x in [0,1],
// solve the cubic-bezier easing curve for its output y, and round-trip
// control-point coords to/from the "cubic-bezier(x1, y1, x2, y2)" CSS string.
//
// Language: Swift 5.9, standard library only.
// Source:   CosmoDev polyglot showcase port of src/lib/animation.ts —
//           display source, part of CosmoDev's tool pages.
// License:  Display source, part of CosmoDev's pages (dev.cosmolabs.org).
//
// The Bézier runs from P0=(0,0) to P3=(1,1) with control points P1=(x1,y1),
// P2=(x2,y2). Every function below is total: it never traps and always
// returns a finite value.

import Foundation

/// The four control-point coordinates [x1, y1, x2, y2].
public typealias BezierCoords = (x1: Double, y1: Double, x2: Double, y2: Double)

/// Named CSS easings expressed as their cubic-bezier control-point coords.
/// An array of pairs keeps the registry dependency-free.
public let easingPresets: [(name: String, coords: BezierCoords)] = [
    ("linear", (0.0, 0.0, 1.0, 1.0)),
    ("ease", (0.25, 0.1, 0.25, 1.0)),
    ("ease-in", (0.42, 0.0, 1.0, 1.0)),
    ("ease-out", (0.0, 0.0, 0.58, 1.0)),
    ("ease-in-out", (0.42, 0.0, 0.58, 1.0)),
]

/// Polynomial coefficients for one axis of the cubic, in power form so the
/// polynomial can be evaluated with nested multiplication.
private struct Coeffs {
    let a: Double
    let b: Double
    let c: Double
}

/// Compute the polynomial coefficients for one axis (c1 and c2 are the
/// control-point coordinates on that axis).
private func bezierCoeffs(_ c1: Double, _ c2: Double) -> Coeffs {
    let c = 3.0 * c1
    let b = 3.0 * (c2 - c1) - c
    let a = 1.0 - c - b
    return Coeffs(a: a, b: b, c: c)
}

/// Evaluate the axis polynomial: ((a·t + b)·t + c)·t (Horner form).
private func sample(_ t: Double, _ k: Coeffs) -> Double {
    ((k.a * t + k.b) * t + k.c) * t
}

/// Derivative of the axis polynomial: (3a·t + 2b)·t + c.
private func sampleDerivative(_ t: Double, _ k: Coeffs) -> Double {
    (3.0 * k.a * t + 2.0 * k.b) * t + k.c
}

/// Coerce non-finite values to 0.0, mirroring the TS Number.isFinite guard.
private func fin(_ v: Double) -> Double {
    v.isFinite ? v : 0.0
}

/// Given animation progress x in [0,1], solve the cubic-bezier easing for the
/// corresponding output y. Newton-Raphson (clamped to [0,1]). Endpoints are
/// exact: y(0)=0 and y(1)=1. Never traps.
public func cubicBezierY(_ x: Double, _ x1: Double, _ y1: Double, _ x2: Double, _ y2: Double) -> Double {
    let px = fin(x)
    if px <= 0.0 {
        return 0.0
    }
    if px >= 1.0 {
        return 1.0
    }

    let xc = bezierCoeffs(fin(x1), fin(x2))
    let yc = bezierCoeffs(fin(y1), fin(y2))

    // Newton-Raphson: find t such that x(t) = px, then read y(t). px is a
    // strong initial guess because x(t) is monotonic for valid curves.
    var t = px
    for _ in 0..<8 {
        let dx = sample(t, xc) - px
        if abs(dx) < 1e-6 {
            break
        }
        let d = sampleDerivative(t, xc)
        if abs(d) < 1e-7 {
            break // guard against division by ~0
        }
        t -= dx / d
    }
    if t < 0.0 {
        t = 0.0
    } else if t > 1.0 {
        t = 1.0
    }
    return sample(t, yc)
}

/// Round a coord to the nearest 1e-6 and render it the way JS String()
/// would: integers without a trailing ".0", and -0.0 normalized to "0".
/// JS Math.round rounds half toward +∞, so floor(x + 0.5) matches it.
private func fmtCoord(_ n: Double) -> String {
    var r = (n * 1e6 + 0.5).rounded(.down) / 1e6
    if r == 0.0 {
        r = 0.0 // normalize -0.0
    }
    if r.truncatingRemainder(dividingBy: 1.0) == 0.0 {
        return String(Int64(r))
    }
    // Swift's default Double description is the shortest round-tripping
    // form, matching JS String() for these 1e-6-rounded values.
    return String(r)
}

/// Format four control-point coords as a CSS "cubic-bezier(...)" string.
public func cssBezier(_ x1: Double, _ y1: Double, _ x2: Double, _ y2: Double) -> String {
    "cubic-bezier(\(fmtCoord(x1)),\(fmtCoord(y1)),\(fmtCoord(x2)),\(fmtCoord(y2)))"
}

/// Validate that the token matches the number grammar "-?\d*\.?\d+" used by the
/// TS regex: an optional leading "-", then digits with at most one "." and at
/// least one digit overall. Rejects exponents, "+", trailing dots, etc.
private func isValidNumberToken(_ s: Substring) -> Bool {
    var chars = s.makeIterator()
    var ch = chars.next()
    if ch == "-" {
        ch = chars.next()
    }
    var seenDot = false
    var seenDigit = false
    while let c = ch {
        switch c {
        case "0"..."9":
            seenDigit = true
        case "." where !seenDot:
            seenDot = true
        default:
            return false
        }
        ch = chars.next()
    }
    return seenDigit
}

/// Parse a CSS "cubic-bezier(x1, y1, x2, y2)" string into its four coords.
/// Returns nil for anything that isn't a valid cubic-bezier() (including
/// named easings like "linear"). Never traps.
///
/// Swift 5.9 has Regex literals, but a hand-rolled scanner mirrors the Rust
/// reference exactly: "cubic-bezier(" followed by four comma-separated
/// numbers, then ")", with optional whitespace wherever a CSS parser would
/// allow it and none between the name and the opening paren.
public func parseCssBezier(_ s: String) -> BezierCoords? {
    var rest = Substring(s)
    // Strip leading whitespace, then the case-insensitive "cubic-bezier(".
    // (No whitespace is allowed between the name and the opening paren.)
    while let f = rest.unicodeScalars.first, CharacterSet.whitespacesAndNewlines.contains(f) {
        rest = Substring(rest.unicodeScalars.dropFirst())
    }
    guard rest.lowercased().hasPrefix("cubic-bezier(") else {
        return nil
    }
    rest = rest.dropFirst("cubic-bezier(".count)

    // Drop trailing whitespace; the last non-ws char must be the closing paren.
    while let l = rest.unicodeScalars.last, CharacterSet.whitespacesAndNewlines.contains(l) {
        rest = Substring(rest.unicodeScalars.dropLast())
    }
    guard rest.hasSuffix(")") else {
        return nil
    }
    let inner = rest.dropLast()

    // Exactly four comma-separated numbers, whitespace permitted around each.
    let fields = inner.split(separator: ",", omittingEmptySubsequences: false)
    guard fields.count == 4 else {
        return nil
    }
    var coords = [Double](repeating: 0.0, count: 4)
    for slot in 0..<4 {
        let token = String(fields[slot]).trimmingCharacters(in: .whitespacesAndNewlines)[...]
        guard isValidNumberToken(token), let v = Double(token), v.isFinite else {
            // Double("inf") etc. parse successfully, so the token grammar
            // check above is what rejects them — both must pass.
            return nil
        }
        coords[slot] = v
    }
    return (coords[0], coords[1], coords[2], coords[3])
}

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 →