Skip to content

CSS Animation Playground — Ruby source

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

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

# css-animation-playground — Ruby 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: Ruby 3.2, 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 raises and always
# returns a finite value.

module CssEasing
  module_function

  # Matches a CSS cubic-bezier(...) string with four numeric args,
  # case-insensitive (the /i flag), mirroring the TS /…/i literal.
  BEZIER_RE = /\A\s*cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,
               \s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)\s*\z/ix.freeze

  # Named CSS easings expressed as their cubic-bezier control-point coords.
  EASING_PRESETS = {
    '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]
  }.freeze

  # Polynomial coefficients [a, b, c] for one axis of the cubic, in power form
  # so the polynomial can be evaluated with nested multiplication. c1 and c2
  # are the control-point coordinates on that axis.
  def bezier_coeffs(c1, c2)
    c = 3.0 * c1
    b = 3.0 * (c2 - c1) - c
    a = 1.0 - c - b
    [a, b, c]
  end

  # Evaluate the axis polynomial: ((a·t + b)·t + c)·t (Horner form).
  def sample(t, coeffs)
    a, b, c = coeffs
    ((a * t + b) * t + c) * t
  end

  # Derivative of the axis polynomial: (3a·t + 2b)·t + c.
  def sample_derivative(t, coeffs)
    a, b, c = coeffs
    (3.0 * a * t + 2.0 * b) * t + c
  end

  # Coerce non-finite values to 0.0, mirroring the TS Number.isFinite guard.
  def fin(v)
    v.finite? ? v : 0.0
  end

  # 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 raises.
  def cubic_bezier_y(x, x1, y1, x2, y2)
    px = fin(x)
    return 0.0 if px <= 0.0
    return 1.0 if px >= 1.0

    x_c = bezier_coeffs(fin(x1), fin(x2))
    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.
    t = px
    8.times do
      dx = sample(t, x_c) - px
      break if dx.abs < 1e-6

      d = sample_derivative(t, x_c)
      break if d.abs < 1e-7 # guard against division by ~0

      t -= dx / d
    end
    t = 0.0 if t < 0.0
    t = 1.0 if t > 1.0
    sample(t, y_c)
  end

  # 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 (x + 0.5).floor matches it.
  def fmt_coord(n)
    r = (n * 1e6 + 0.5).floor / 1e6
    r = 0.0 if r == 0.0 # normalize -0.0
    if r % 1.0 == 0.0
      r.to_i.to_s
    else
      # Float#to_s is the shortest round-tripping form, matching JS String()
      # for these 1e-6-rounded values.
      r.to_s
    end
  end

  # Format four control-point coords as a CSS "cubic-bezier(...)" string.
  def css_bezier(x1, y1, x2, y2)
    format('cubic-bezier(%s,%s,%s,%s)', fmt_coord(x1), fmt_coord(y1), fmt_coord(x2), fmt_coord(y2))
  end

  # Parse a CSS "cubic-bezier(x1, y1, x2, y2)" string into its four coords.
  # Returns nil for anything that isn't a valid cubic-bezier() (including
  # named easings like "linear"). Never raises.
  def parse_css_bezier(s)
    return nil unless s.is_a?(String)

    m = BEZIER_RE.match(s)
    return nil if m.nil?

    # The regex already validated the token grammar; Kernel#Float keeps the
    # "never raises" contract total by mapping parse failure to nil.
    coords = (1..4).map do |i|
      begin
        Float(m[i])
      rescue ArgumentError, TypeError
        nil
      end
    end
    return nil if coords.any? { |c| c.nil? || !c.finite? }

    coords
  end
end

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 →