Skip to content

CSS Animation Playground — C# source

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

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

// css-animation-playground — C# 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: C# 12 (.NET 8), standard library only (System.Text.RegularExpressions).
// 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.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;

namespace CosmoDev.Animation;

/// The four control-point coordinates (X1, Y1, X2, Y2).
public readonly record struct BezierCoords(double X1, double Y1, double X2, double Y2);

public static class CssEasing
{
    /// Matches a CSS cubic-bezier(...) string with four numeric args,
    /// case-insensitive — mirroring the TS /…/i literal.
    private static readonly Regex BezierRegex = new(
        @"^\s*cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)\s*$",
        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);

    /// Named CSS easings expressed as their cubic-bezier control-point coords.
    public static readonly IReadOnlyDictionary<string, BezierCoords> EasingPresets =
        new Dictionary<string, BezierCoords> {
            ["linear"] = new(0.0, 0.0, 1.0, 1.0),
            ["ease"] = new(0.25, 0.1, 0.25, 1.0),
            ["ease-in"] = new(0.42, 0.0, 1.0, 1.0),
            ["ease-out"] = new(0.0, 0.0, 0.58, 1.0),
            ["ease-in-out"] = new(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 readonly record struct Coeffs(double A, double B, double C);

    /// Compute the polynomial coefficients for one axis (<paramref name="c1"/>
    /// and <paramref name="c2"/> are the control-point coordinates on that axis).
    private static Coeffs BezierCoeffs(double c1, double c2)
    {
        double c = 3.0 * c1;
        double b = 3.0 * (c2 - c1) - c;
        double a = 1.0 - c - b;
        return new Coeffs(a, b, c);
    }

    /// Evaluate the axis polynomial: ((a·t + b)·t + c)·t (Horner form).
    private static double Sample(double t, in Coeffs k) => ((k.A * t + k.B) * t + k.C) * t;

    /// Derivative of the axis polynomial: (3a·t + 2b)·t + c.
    private static double SampleDerivative(double t, in Coeffs k) => (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 static double Fin(double v) => double.IsFinite(v) ? v : 0.0;

    /// Given animation progress <paramref name="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 static double CubicBezierY(double x, double x1, double y1, double x2, double y2)
    {
        double px = Fin(x);
        if (px <= 0.0)
        {
            return 0.0;
        }
        if (px >= 1.0)
        {
            return 1.0;
        }

        Coeffs xc = BezierCoeffs(Fin(x1), Fin(x2));
        Coeffs 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.
        double t = px;
        for (int i = 0; i < 8; i++)
        {
            double dx = Sample(t, in xc) - px;
            if (Math.Abs(dx) < 1e-6)
            {
                break;
            }
            double d = SampleDerivative(t, in xc);
            if (Math.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, in 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 Math.Floor(x + 0.5) matches it.
    private static string FmtCoord(double n)
    {
        double r = Math.Floor(n * 1e6 + 0.5) / 1e6;
        if (r == 0.0)
        {
            r = 0.0; // normalize -0.0
        }
        // .NET's invariant "R"-free default double formatting is shortest
        // round-trippable, matching JS String() for these 1e-6-rounded values.
        return r % 1.0 == 0.0
            ? ((long)r).ToString(CultureInfo.InvariantCulture)
            : r.ToString(CultureInfo.InvariantCulture);
    }

    /// Format four control-point coords as a CSS "cubic-bezier(...)" string.
    public static string CssBezier(double x1, double y1, double x2, double y2) =>
        string.Create(
            CultureInfo.InvariantCulture,
            $"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 static BezierCoords? ParseCssBezier(string? s)
    {
        if (s is null)
        {
            return null;
        }
        Match m = BezierRegex.Match(s);
        if (!m.Success)
        {
            return null;
        }
        double[] coords = new double[4];
        for (int i = 0; i < 4; i++)
        {
            if (!double.TryParse(m.Groups[i + 1].Value, NumberStyles.Float,
                    CultureInfo.InvariantCulture, out double v) || !double.IsFinite(v))
            {
                return null;
            }
            coords[i] = v;
        }
        return new BezierCoords(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 →