Skip to content

Animated Art

Five animations that showcase what's possible when you combine PyFreeform's animation system with parametric curves, fractals, and algorithmic geometry. Every SVG below is pure SMIL — open in a browser to watch it play.

Browser preview

Animated SVGs play in web browsers. They won't animate in image viewers or editors like Inkscape.

Mandelbrot Set

The Mandelbrot set revealed iteration by iteration on a 100×100 grid. Each cell maps to a point on the complex plane, colored by escape iteration. The set assembles band-by-band, holds, then dissolves in reverse — looping forever:

from pyfreeform import Scene, Rect
from pyfreeform.color import hsl

cols, rows = 100, 100
scene = Scene(cols * 4, rows * 4, background="#0a0a1a")
max_iter = 50

# Map grid to complex plane: x ∈ [-2, 0.5], y ∈ [-1.25, 1.25]
x_min, x_max = -2.0, 0.5
y_min, y_max = -1.25, 1.25

by_iter = {}

for row in range(rows):
    for col in range(cols):
        cx = x_min + (col + 0.5) / cols * (x_max - x_min)
        cy = y_min + (row + 0.5) / rows * (y_max - y_min)
        c = complex(cx, cy)

        z = 0 + 0j
        escape = max_iter
        for i in range(max_iter):
            z = z * z + c
            if z.real * z.real + z.imag * z.imag > 4:
                escape = i
                break

        x_px, y_px = col * 4, row * 4
        if escape == max_iter:
            scene.place(Rect(x_px, y_px, 4, 4, fill="#0c0c2a"))
            continue

        t = escape / max_iter
        hue = (240 + t * 300) % 360
        color = hsl(hue, 0.85, 0.35 + 0.3 * t)
        r = Rect(x_px, y_px, 4, 4, fill=color, opacity=0.0)
        scene.place(r)
        by_iter.setdefault(escape, []).append(r)

# Compute per-band delays, then animate with bounce
delay = 0.0
band_delays = []
for i in sorted(by_iter):
    band_delays.append((delay, by_iter[i]))
    delay += 0.06

forward_time = delay + 0.5

for appear, fills in band_delays:
    for fill in fills:
        fill.animate_fade(
            keyframes={0: 0, appear: 0, appear + 0.4: 1.0,
                       forward_time: 1.0},
        )
        fill.loop(bounce=True)

Mandelbrot set animation

The Mandelbrot set assembling itself, then dissolving in reverse — the fractal boundary is the last to appear and first to vanish.

The Mandelbrot set

For each point c in the complex plane, iterate zz² + c starting from z = 0. If z stays bounded, c is in the set. The boundary between "escapes" and "stays" is infinitely detailed — zoom in anywhere on the edge and you'll find miniature copies of the whole set.


Lissajous Harmonograph

A dot traces a Lissajous curve in real time while the path draws itself behind it. The frequency ratio a=5, b=4 creates an intricate closed knot:

import math
from pyfreeform import Scene
from pyfreeform.paths import Lissajous

scene = Scene.with_grid(cols=1, rows=1, cell_size=400, background="#0a0a1a")
cell = scene.grid[0][0]

liss = Lissajous(center=(0.5, 0.5), a=5, b=4, delta=math.pi / 2, size=0.38)

# The curve draws itself (relative=True scales 0–1 coords to pixels)
path = cell.add_path(liss, relative=True, width=2, color="mediumpurple", opacity=0.7)
path.animate_draw(duration=6.0, easing="linear")

# liss.point_at(0.0) is already in relative (0–1) space
start = liss.point_at(0.0)

# A dot follows the same curve
tracer = cell.add_dot(at=(start.x, start.y), radius=0.015, color="coral")
tracer.animate_follow(path, duration=6.0, easing="linear", repeat=True)

# Glowing center — pulse radius only
glow = cell.add_dot(at=(start.x, start.y), radius=0.008, color="white")
glow.animate_follow(path, duration=6.0, easing="linear", repeat=True)
glow.animate_radius(to=8, duration=0.8, easing="ease-in-out", bounce=True, repeat=True)

Lissajous harmonograph

A 5:4 Lissajous curve drawing itself while a dot traces the path — like a mathematical Spirograph.

Lissajous curves

A Lissajous figure is defined by x = sin(a·t + δ), y = sin(b·t). When the frequency ratio a/b is rational, the curve closes. Different ratios produce wildly different patterns — try a=3, b=2 for a figure-eight, or a=7, b=5 for a complex star-knot.


Spiral Galaxy

Stars bloom outward in golden-angle phyllotaxis order. Each star fades in with staggered timing, recreating the way a spiral galaxy's arms emerge:

import math
from pyfreeform import Scene, Polygon, stagger
from pyfreeform.color import hsl

scene = Scene(440, 440, background="#050510")
cx, cy = 220, 220
golden_angle = 137.508
max_r = 440 * 0.44

stars = []
for i in range(1, 201):
    angle = math.radians(i * golden_angle)
    t = i / 200
    r = max_r * math.sqrt(t)
    x = cx + r * math.cos(angle)
    y = cy + r * math.sin(angle)

    hue = (40 - t * 220) % 360
    star_size = 2.0 + 4.0 * (1 - t)
    dot = Polygon(Polygon.star(size=star_size, center=(x, y)),
                  fill=hsl(hue, 0.85, 0.55), opacity=0.0)
    scene.place(dot)
    stars.append(dot)

# Stagger: each star fades in with offset timing
stagger(*stars, offset=0.02,
        each=lambda d: d.animate_fade(to=0.9, duration=0.5, easing="ease-out"))

# Some stars spin for a twinkling effect
for i, dot in enumerate(stars):
    if i % 5 == 0:
        dot.animate_spin(360, duration=8.0 + (i % 3) * 2, easing="linear")
        dot.loop()

Spiral galaxy animation

200 stars blooming in golden-angle order — the same pattern that arranges sunflower seeds and galaxy arms.

The golden angle

The golden angle (137.508°) is 360° / φ² where φ is the golden ratio. Placing points at successive golden angles produces the most uniform distribution possible — no two arms ever align, creating the natural spiral patterns found throughout nature.


Breathing Mandala

Concentric rings of dots pulse in and out with phase offsets, creating a hypnotic breathing pattern. Each ring starts its cycle slightly after the previous one:

import math
from pyfreeform import Scene, Dot

scene = Scene(420, 420, background="#0a0a1a")
cx, cy = 210, 210

n_rings = 6
dots_per_ring = [8, 12, 16, 20, 24, 28]
ring_colors = ["coral", "gold", "#ff6b9d", "skyblue", "mediumpurple", "limegreen"]

for ring_idx in range(n_rings):
    n = dots_per_ring[ring_idx]
    r = 30 + ring_idx * 30
    color = ring_colors[ring_idx]
    phase_delay = ring_idx * 0.3

    for j in range(n):
        angle = 2 * math.pi * j / n + ring_idx * 0.15
        x = cx + r * math.cos(angle)
        y = cy + r * math.sin(angle)
        dot = Dot(x, y, radius=4, color=color)
        scene.place(dot)

        per_dot_delay = phase_delay + j * 0.05
        dot.animate_radius(to=10, duration=2.0, delay=per_dot_delay,
                           easing="ease-in-out", bounce=True, repeat=True)
        if j % 2 == 0:
            dot.animate_fade(to=0.3, duration=2.0, delay=per_dot_delay,
                             easing="ease-in-out", bounce=True, repeat=True)

# Center jewel
center = Dot(cx, cy, radius=8, color="white")
scene.place(center)
center.animate_radius(to=16, duration=1.5, easing="ease-in-out", bounce=True, repeat=True)
center.animate_spin(360, duration=6.0, easing="linear", bounce=True, repeat=True)

Breathing mandala

Six concentric rings of dots pulsing with phase-offset timing — a digital mandala that breathes.

Sierpinski Triangle

A Sierpinski triangle that cuts itself out depth by depth. A solid triangle appears first, then progressively smaller center holes are punched out to reveal the fractal:

from pyfreeform import Scene, Polygon

scene = Scene(420, 420, background="#0a0a1a")
bg = "#0a0a1a"
max_depth = 5

margin = 420 * 0.08
top = (210, margin)
bl = (margin, 420 - margin)
br = (420 - margin, 420 - margin)

def midpoint(a, b):
    return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)

# Collect all (entity, appear_time, target_opacity, fade_duration)
outer = Polygon([top, bl, br], fill="#ff6b6b", stroke="#ff6b6b",
                stroke_width=0.5, opacity=0.0)
scene.place(outer)
elements = [(outer, 0.0, 0.85, 0.6)]

# Build holes depth by depth
corners = [(top, bl, br)]
holes_by_depth = {}
for d in range(1, max_depth + 1):
    holes, next_corners = [], []
    for v0, v1, v2 in corners:
        m01, m12, m02 = midpoint(v0, v1), midpoint(v1, v2), midpoint(v0, v2)
        holes.append((m01, m12, m02))
        next_corners.extend([(v0, m01, m02), (m01, v1, m12), (m02, m12, v2)])
    holes_by_depth[d] = holes
    corners = next_corners

total_delay = 0.8
for d in range(1, max_depth + 1):
    holes = holes_by_depth[d]
    per_hole = min(0.04, 1.2 / max(len(holes), 1))
    for k, (h0, h1, h2) in enumerate(holes):
        hole = Polygon([h0, h1, h2], fill=bg, stroke=bg,
                       stroke_width=0.3, opacity=0.0)
        scene.place(hole)
        elements.append((hole, total_delay + k * per_hole, 1.0, 0.3))
    total_delay += 1.2 + 0.3

# Animate with keyframes so the whole sequence bounces as a unit
forward_time = total_delay + 0.5
for entity, appear, target, dur in elements:
    entity.animate_fade(
        keyframes={0: 0, appear: 0, appear + dur: target, forward_time: target},
        repeat=True, bounce=True,
    )

Sierpinski triangle

A solid triangle with progressively smaller holes punched out — the Sierpinski pattern emerging depth by depth.

Sierpinski's triangle

The Sierpinski triangle is one of the simplest fractals: start with a triangle, remove the center, and repeat on each remaining sub-triangle. After n iterations you have 3n triangles, each at 1/2 the scale. The total area shrinks to zero while the structure retains infinite detail — a hallmark of fractal geometry.


What's Next?

These recipes barely scratch the surface. Try combining techniques:

  • Lissajous + color keyframes: Animate fill color as a dot traces the curve
  • Galaxy + connections: Connect nearby stars with self-drawing connections
  • Mandala + .then(): Sequentially build each ring, then start the breathing animation
  • Mandelbrot + zoom: Animate into the boundary by narrowing the complex-plane window each frame

← Hidden Gems