Skip to content

CSS Animation Playground — Kotlin source

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

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

// css-animation-playground — Kotlin 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: Kotlin 1.9 (JVM), 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 throws and always
// returns a finite value.

/** The four control-point coordinates [x1, y1, x2, y2]. */
public typealias BezierCoords = DoubleArray

/** Polynomial coefficients for one axis of the cubic, in power form so the
 *  polynomial can be evaluated with nested multiplication. */
private class Coeffs(val a: Double, val b: Double, val c: Double)

/** Named CSS easings expressed as their cubic-bezier control-point coords. */
public val EASING_PRESETS: Map<String, BezierCoords> = linkedMapOf(
    "linear" to doubleArrayOf(0.0, 0.0, 1.0, 1.0),
    "ease" to doubleArrayOf(0.25, 0.1, 0.25, 1.0),
    "ease-in" to doubleArrayOf(0.42, 0.0, 1.0, 1.0),
    "ease-out" to doubleArrayOf(0.0, 0.0, 0.58, 1.0),
    "ease-in-out" to doubleArrayOf(0.42, 0.0, 0.58, 1.0),
)

/** Matches a CSS cubic-bezier(...) string with four numeric args,
 *  case-insensitive — mirroring the TS /…/i literal. */
private val BEZIER_RE = Regex(
    """^\s*cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)\s*$""",
    RegexOption.IGNORE_CASE,
)

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

/** Evaluate the axis polynomial: ((a·t + b)·t + c)·t (Horner form). */
private fun 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 fun 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 fun fin(v: Double): Double = if (v.isFinite()) v else 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 throws.
 */
public fun cubicBezierY(x: Double, x1: Double, y1: Double, x2: Double, y2: Double): Double {
    val px = fin(x)
    if (px <= 0.0) return 0.0
    if (px >= 1.0) return 1.0

    val xc = bezierCoeffs(fin(x1), fin(x2))
    val 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
    repeat(8) {
        val dx = sample(t, xc) - px
        if (kotlin.math.abs(dx) < 1e-6) return@repeat
        val d = sampleDerivative(t, xc)
        if (kotlin.math.abs(d) < 1e-7) return@repeat // 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 fun fmtCoord(n: Double): String {
    var r = kotlin.math.floor(n * 1e6 + 0.5) / 1e6
    if (r == 0.0) r = 0.0 // normalize -0.0
    return if (r % 1.0 == 0.0) {
        r.toLong().toString()
    } else {
        // Kotlin/Java Double.toString is the shortest round-tripping form,
        // matching JS String() for these 1e-6-rounded values.
        r.toString()
    }
}

/** Format four control-point coords as a CSS "cubic-bezier(...)" string. */
public fun cssBezier(x1: Double, y1: Double, x2: Double, y2: Double): String =
    "cubic-bezier(${fmtCoord(x1)},${fmtCoord(y1)},${fmtCoord(x2)},${fmtCoord(y2)})"

/**
 * Parse a CSS "cubic-bezier(x1, y1, x2, y2)" string into its four coords.
 * Returns null for anything that isn't a valid cubic-bezier() (including
 * named easings like "linear"). Never throws.
 */
public fun parseCssBezier(s: String?): BezierCoords? {
    if (s == null) return null
    val m = BEZIER_RE.matchEntire(s) ?: return null
    val coords = DoubleArray(4)
    for (i in 0 until 4) {
        val v = m.groupValues[i + 1].toDoubleOrNull() ?: return null
        if (!v.isFinite()) return null
        coords[i] = v
    }
    return coords
}

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 →