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 →