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 →