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)
The Mandelbrot set
For each point c in the complex plane, iterate z → z² + 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 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()
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)
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'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