Skip to content

CSS Animation Playground — Zig source

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

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

//! css-animation-playground — Zig 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. Standard library only, fully deterministic.
//!
//! Language: Zig 0.13, 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 panics
//! and always returns a finite value. Zig has no Option type in the stdlib's
//! public surface used here, so `parseCssBezier` returns a null-able
//! optional via `?Coords` and takes an allocator for its string scratch.

const std = @import("std");

/// The four control-point coordinates `{ x1, y1, x2, y2 }`.
pub const Coords = struct { x1: f64, y1: f64, x2: f64, y2: f64 };

/// 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 iterate it.
pub const EasingPreset = struct { name: []const u8, coords: Coords };

pub const easing_presets = [_]EasingPreset{
    .{ .name = "linear", .coords = .{ .x1 = 0.0, .y1 = 0.0, .x2 = 1.0, .y2 = 1.0 } },
    .{ .name = "ease", .coords = .{ .x1 = 0.25, .y1 = 0.1, .x2 = 0.25, .y2 = 1.0 } },
    .{ .name = "ease-in", .coords = .{ .x1 = 0.42, .y1 = 0.0, .x2 = 1.0, .y2 = 1.0 } },
    .{ .name = "ease-out", .coords = .{ .x1 = 0.0, .y1 = 0.0, .x2 = 0.58, .y2 = 1.0 } },
    .{ .name = "ease-in-out", .coords = .{ .x1 = 0.42, .y1 = 0.0, .x2 = 0.58, .y2 = 1.0 } },
};

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

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

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

/// Derivative of the axis polynomial: `(3a·t + 2b)·t + c`.
fn sampleDerivative(t: f64, k: Coeffs) f64 {
    return (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.
fn fin(v: f64) f64 {
    return if (std.math.isFinite(v)) 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 cubicBezierY(x: f64, x1: f64, y1: f64, x2: f64, y2: f64) f64 {
    const px = fin(x);
    if (px <= 0.0) return 0.0;
    if (px >= 1.0) return 1.0;

    const xc = bezierCoeffs(fin(x1), fin(x2));
    const 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;
    var i: usize = 0;
    while (i < 8) : (i += 1) {
        const dx = sample(t, xc) - px;
        if (@abs(dx) < 1e-6) break;
        const 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.
fn fmtCoord(buf: []u8, n: f64) []const u8 {
    var r = @floor(n * 1e6 + 0.5) / 1e6;
    if (r == 0.0) r = 0.0; // normalize -0.0
    if (@mod(r, 1.0) == 0.0) {
        // Integer-valued: print without a trailing ".0".
        const as_int: i64 = @intFromFloat(r);
        return std.fmt.bufPrint(buf, "{d}", .{as_int}) catch "0";
    }
    // `{d}` on a float is the shortest round-tripping decimal form,
    // matching JS String() for these 1e-6-rounded values.
    return std.fmt.bufPrint(buf, "{d}", .{r}) catch "0";
}

/// Format four control-point coords as a CSS `cubic-bezier(...)` string into
/// the caller-provided buffer (32 bytes per coord is ample; use ≥ 64 total).
pub fn cssBezier(buf: []u8, x1: f64, y1: f64, x2: f64, y2: f64) []const u8 {
    var scratch: [4][32]u8 = undefined;
    const a = fmtCoord(&scratch[0], x1);
    const b = fmtCoord(&scratch[1], y1);
    const c = fmtCoord(&scratch[2], x2);
    const d = fmtCoord(&scratch[3], y2);
    return std.fmt.bufPrint(buf, "cubic-bezier({s},{s},{s},{s})", .{ a, b, c, d }) catch "cubic-bezier(0,0,1,1)";
}

/// Case-insensitive ASCII prefix strip.
fn stripPrefixCi(s: []const u8, prefix: []const u8) ?[]const u8 {
    if (s.len < prefix.len) return null;
    const head = s[0..prefix.len];
    for (head, 0..) |ch, i| {
        const lower = std.ascii.toLower(ch);
        const want = std.ascii.toLower(prefix[i]);
        if (lower != want) return null;
    }
    return s[prefix.len..];
}

fn isSpace(ch: u8) bool {
    return ch == ' ' or ch == '\t' or ch == '\n' or ch == '\r' or ch == 0x0b or ch == 0x0c;
}

/// 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.
fn isValidNumberToken(s: []const u8) bool {
    var i: usize = 0;
    if (i < s.len and s[i] == '-') i += 1;
    var seen_dot = false;
    var seen_digit = false;
    while (i < s.len) : (i += 1) {
        switch (s[i]) {
            '0'...'9' => seen_digit = true,
            '.' => {
                if (seen_dot) return false;
                seen_dot = true;
            },
            else => return false,
        }
    }
    return seen_digit;
}

/// 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 panics, never allocates.
///
/// Zig's stdlib has no regex, so this hand-rolls the same grammar the TS
/// regex encodes: `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.
pub fn parseCssBezier(s: []const u8) ?Coords {
    // Strip leading whitespace, then the case-insensitive "cubic-bezier(".
    // (No whitespace is allowed between the name and the opening paren.)
    var rest = s;
    while (rest.len > 0 and isSpace(rest[0])) rest = rest[1..];
    var inner = stripPrefixCi(rest, "cubic-bezier(") orelse return null;

    // Drop trailing whitespace; the last non-ws char must be the closing paren.
    while (inner.len > 0 and isSpace(inner[inner.len - 1])) inner = inner[0 .. inner.len - 1];
    if (inner.len == 0 or inner[inner.len - 1] != ')') return null;
    inner = inner[0 .. inner.len - 1];

    // Walk exactly four comma-separated numbers, whitespace allowed around each.
    var coords: [4]f64 = undefined;
    var pos: usize = 0;
    var slot: usize = 0;
    while (slot < 4) : (slot += 1) {
        // Find the next ',' (the fourth coord runs to the end of the inner text).
        var stop = pos;
        while (stop < inner.len and inner[stop] != ',') stop += 1;
        if (slot < 3 and stop >= inner.len) return null; // fewer than four fields

        // Trim the field, validate its token, and convert it.
        var lo = pos;
        var hi = stop;
        while (lo < hi and isSpace(inner[lo])) lo += 1;
        while (hi > lo and isSpace(inner[hi - 1])) hi -= 1;
        const token = inner[lo..hi];
        if (!isValidNumberToken(token)) return null;

        // parseFloat also accepts "inf"/"nan"; the token grammar above is
        // what rejects them, so both checks must pass.
        const v = std.fmt.parseFloat(f64, token) catch return null;
        if (!std.math.isFinite(v)) return null;
        coords[slot] = v;

        pos = stop + 1; // skip the ',' just consumed
    }
    if (pos != inner.len + 1) return null; // a fifth field means this wasn't a 4-arg call

    return .{ .x1 = coords[0], .y1 = coords[1], .x2 = coords[2], .y2 = 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 →