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 →