Skip to content

CSS Animation Playground — Rust source

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

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

//! css-animation-playground — Rust 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. No external crates, fully deterministic.
//!
//! Ported from `src/lib/animation.ts` — display source, part of CosmoDev's
//! polyglot tool pages (dev.cosmolabs.org). Behavior is functionally equivalent
//! to the canonical TypeScript implementation.
//!
//! 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 panics and
//! always returns a finite value.

/// The four control-point coordinates `[x1, y1, x2, y2]`.
pub type BezierCoords = [f64; 4];

/// Polynomial coefficients for one axis of the cubic, in power form so the
/// polynomial can be evaluated with nested multiplication.
#[derive(Clone, Copy)]
struct Coeffs {
    a: f64,
    b: f64,
    c: f64,
}

/// Compute the polynomial coefficients for one axis (`c1`, `c2` are the
/// control-point coordinates on that axis).
fn bezier_coeffs(c1: f64, c2: f64) -> Coeffs {
    let c = 3.0 * c1;
    let b = 3.0 * (c2 - c1) - c;
    let a = 1.0 - c - b;
    Coeffs { a, b, c }
}

/// Evaluate the axis polynomial: `((a·t + b)·t + c)·t` (Horner form).
fn sample(t: f64, k: Coeffs) -> f64 {
    ((k.a * t + k.b) * t + k.c) * t
}

/// Derivative of the axis polynomial: `(3a·t + 2b)·t + c`.
fn sample_derivative(t: f64, k: Coeffs) -> f64 {
    (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.
#[inline]
fn fin(v: f64) -> f64 {
    if v.is_finite() {
        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 panics.
pub fn cubic_bezier_y(x: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
    // Total function: coerce non-finite inputs to 0, clamp progress, guarantee endpoints.
    let px = fin(x);
    if px <= 0.0 {
        return 0.0;
    }
    if px >= 1.0 {
        return 1.0;
    }

    let x_c = bezier_coeffs(fin(x1), fin(x2));
    let y_c = bezier_coeffs(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.
    let mut t = px;
    for _ in 0..8 {
        let dx = sample(t, x_c) - px;
        if dx.abs() < 1e-6 {
            break;
        }
        let d = sample_derivative(t, x_c);
        if d.abs() < 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;
    }
    sample(t, y_c)
}

/// 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"`.
fn fmt_coord(n: f64) -> String {
    // JS Math.round rounds half toward +∞; f64::round is half-away-from-zero,
    // so floor(x + 0.5) is used to match JS exactly on negative halves.
    let r = (n * 1e6 + 0.5).floor() / 1e6;
    // Rust's float Display already omits the trailing ".0" on integer-valued
    // floats, so it matches JS String() once -0.0 is normalized to "0".
    let r = if r == 0.0 { 0.0 } else { r };
    format!("{}", r)
}

/// Format four control-point coords as a CSS `cubic-bezier(...)` string.
pub fn css_bezier(x1: f64, y1: f64, x2: f64, y2: f64) -> String {
    format!(
        "cubic-bezier({},{},{},{})",
        fmt_coord(x1),
        fmt_coord(y1),
        fmt_coord(x2),
        fmt_coord(y2),
    )
}

/// Parse a CSS `cubic-bezier(x1, y1, x2, y2)` string into its four coords.
/// Returns `None` for anything that isn't a valid `cubic-bezier()` (including
/// named easings like `"linear"`). Never panics.
///
/// The stdlib has no regex, so this hand-rolls the same grammar the TS regex
/// encodes: `cubic-bezier(` followed by four comma-separated numbers (each
/// matching `-?\d*\.?\d+`), then `)`, with optional whitespace wherever a CSS
/// parser would allow it and none between the name and the opening paren.
pub fn parse_css_bezier(s: &str) -> Option<BezierCoords> {
    // Strip leading whitespace, then the case-insensitive `cubic-bezier(`.
    // (No whitespace is allowed between the name and the opening paren.)
    let rest = s.trim_start();
    let rest = strip_prefix_ci(rest, "cubic-bezier(")?;

    // Drop trailing whitespace; the last non-ws char must be the closing paren.
    let rest = rest.trim_end();
    if !rest.ends_with(')') {
        return None;
    }
    let inner = &rest[..rest.len() - 1];

    // Exactly four comma-separated numbers, whitespace permitted around each.
    let mut parts = inner.split(',');
    let mut coords = [0.0f64; 4];
    for slot in coords.iter_mut() {
        let part = parts.next()?;
        let token = part.trim();
        if !is_valid_number_token(token) {
            return None;
        }
        let v: f64 = token.parse().ok()?;
        if !v.is_finite() {
            return None;
        }
        *slot = v;
    }
    // A fifth comma-separated field means this wasn't a 4-arg call.
    if parts.next().is_some() {
        return None;
    }
    Some(coords)
}

/// Validate that `s` 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.
fn is_valid_number_token(s: &str) -> bool {
    let bytes = s.as_bytes();
    let mut i = 0;
    if i < bytes.len() && bytes[i] == b'-' {
        i += 1;
    }
    let mut seen_dot = false;
    let mut seen_digit = false;
    for &b in &bytes[i..] {
        match b {
            b'0'..=b'9' => seen_digit = true,
            b'.' if !seen_dot => seen_dot = true,
            _ => return false,
        }
    }
    seen_digit
}

/// Case-insensitive ASCII prefix strip that stays on UTF-8 char boundaries.
/// `s.get(..n)` returns `None` off-boundary or out-of-range, so the slice
/// never panics on malformed UTF-8 input.
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
    let head = s.get(..prefix.len())?;
    if head.eq_ignore_ascii_case(prefix) {
        Some(&s[prefix.len()..])
    } else {
        None
    }
}

/// Named CSS easings expressed as their cubic-bezier control-point coords.
/// A slice of pairs keeps the registry dependency-free; callers building a
/// lookup map can collect it from here.
pub const EASING_PRESETS: &[(&str, 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]),
];

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 →