Skip to content

CSS Animation Playground — Python source

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

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

"""css-animation-playground — Python 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 third-party dependencies, 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 raises and always returns a
finite value.
"""

from __future__ import annotations

import math
import re
from typing import Dict, Optional, Tuple

# The four control-point coordinates as (x1, y1, x2, y2).
BezierCoords = Tuple[float, float, float, float]

# Matches a CSS cubic-bezier(...) string with four numeric args. Case-insensitive
# (the re.IGNORECASE flag), mirroring the TS /…/i literal.
_BEZIER_RE = re.compile(
    r"^\s*cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,"
    r"\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)\s*$",
    re.IGNORECASE,
)


def _bezier_coeffs(c1: float, c2: float) -> Tuple[float, float, float]:
    """Polynomial coefficients (a, b, c) for one axis of the cubic.

    The cubic is rewritten in power form so it can be evaluated with nested
    multiplication; ``c1`` and ``c2`` are the control-point coords on that axis.
    """
    c = 3.0 * c1
    b = 3.0 * (c2 - c1) - c
    a = 1.0 - c - b
    return (a, b, c)


def _sample(t: float, coeffs: Tuple[float, float, float]) -> float:
    """Evaluate the axis polynomial: ((a·t + b)·t + c)·t (Horner form)."""
    a, b, c = coeffs
    return ((a * t + b) * t + c) * t


def _sample_derivative(t: float, coeffs: Tuple[float, float, float]) -> float:
    """Derivative of the axis polynomial: (3a·t + 2b)·t + c."""
    a, b, c = coeffs
    return (3.0 * a * t + 2.0 * b) * t + c


def _fin(v: float) -> float:
    """Coerce non-finite values to 0.0, mirroring the TS Number.isFinite guard."""
    return v if math.isfinite(v) else 0.0


def cubic_bezier_y(x: float, x1: float, y1: float, x2: float, y2: float) -> float:
    """Solve the cubic-bezier easing for the output ``y`` given progress ``x``.

    Newton-Raphson (clamped to [0,1]). Endpoints are exact: y(0)=0, y(1)=1.
    Non-finite inputs are coerced to 0. Never raises.
    """
    px = _fin(x)
    if px <= 0.0:
        return 0.0
    if px >= 1.0:
        return 1.0

    x_c = _bezier_coeffs(_fin(x1), _fin(x2))
    y_c = _bezier_coeffs(_fin(y1), _fin(y2))

    # px is a strong initial guess because x(t) is monotonic for valid curves.
    t = px
    for _ in range(8):
        dx = _sample(t, x_c) - px
        if abs(dx) < 1e-6:
            break
        d = _sample_derivative(t, x_c)
        if abs(d) < 1e-7:  # guard against division by ~0
            break
        t -= dx / d
    if t < 0.0:
        t = 0.0
    elif t > 1.0:
        t = 1.0
    return _sample(t, y_c)


def _fmt_coord(n: float) -> str:
    """Render a coord as JS ``String()`` would.

    JS ``Math.round`` rounds half toward +∞, so we use ``floor(x + 0.5)`` to
    match it exactly (Python's built-in ``round`` uses banker's rounding, which
    would diverge on half-millionth boundaries). Integer-valued results drop
    the trailing ``.0`` and ``-0.0`` normalizes to ``"0"``.
    """
    r = math.floor(n * 1e6 + 0.5) / 1e6  # half-up, matching JS Math.round
    if r == 0.0:  # normalize -0.0 → "0"
        r = 0.0
    if r == int(r):
        return str(int(r))
    return repr(r)


def css_bezier(x1: float, y1: float, x2: float, y2: float) -> str:
    """Format four control-point coords as a CSS ``cubic-bezier(...)`` string."""
    return "cubic-bezier({},{},{},{})".format(
        _fmt_coord(x1), _fmt_coord(y1), _fmt_coord(x2), _fmt_coord(y2)
    )


def parse_css_bezier(s: object) -> Optional[BezierCoords]:
    """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 raises.
    """
    if not isinstance(s, str):
        return None
    m = _BEZIER_RE.match(s)
    if m is None:
        return None
    coords = [float(m.group(i)) for i in range(1, 5)]
    if any(not math.isfinite(c) for c in coords):
        return None
    return (coords[0], coords[1], coords[2], coords[3])


#: Named CSS easings expressed as their cubic-bezier control-point coords.
EASING_PRESETS: Dict[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 →