Animation¶
PyFreeform supports animations on any entity. Call a method like .animate_fade() or .animate_spin(), save as SVG, and open in a browser to see it play. Under the hood, animations use SVG SMIL — no JavaScript required.
Browser preview
Animated SVGs play automatically when opened in a web browser. They won't animate in image viewers or editors.
Fade¶
Animate opacity with .animate_fade(). Pass the target opacity and a duration:
dot = cell.add_dot(at="center", radius=0.2, color="coral")
dot.animate_fade(to=0.0, duration=3.0, easing="ease-in-out")
dot.loop(bounce=True)
The dot pulses — fading to invisible and back again. .loop(bounce=True) reverses each cycle and loops forever.
Fade to any value — to=0.3 makes the entity ghostly, to=1.0 animates it in (if it started transparent).
Spin¶
Rotate an entity with .animate_spin():
rect = cell.add_rect(at="center", width=0.38, height=0.38,
fill="dodgerblue", stroke="white", stroke_width=2)
rect.animate_spin(360, duration=2.5, easing="linear")
rect.loop()
The first argument is the total rotation angle in degrees. Call .loop() for continuous spinning.
Scale¶
Animate an entity's scale with .animate_scale():
dot = cell.add_dot(at="center", radius=0.08, color="tomato")
dot.animate_scale(to=2.0, duration=2.0, easing="ease-in-out")
dot.loop(bounce=True)
.animate_scale(to=) animates the scale factor over time. This is the animated counterpart to .scale() — just as .animate_spin() is to .rotate().
Draw¶
Lines, curves, paths, and connections can draw themselves with .animate_draw() — the stroke reveals progressively like a pen tracing the shape:
from pyfreeform.paths import Wave
w, h = cell.width, cell.height
wave_shape = Wave(start=(w * 0.08, h * 0.4), end=(w * 0.92, h * 0.4),
amplitude=h * 0.28, frequency=3)
path = cell.add_path(wave_shape, width=3, color="limegreen")
path.animate_draw(duration=2.5, easing="ease-in-out")
path.loop(bounce=True)
The wave draws itself left to right, then un-draws back — .loop(bounce=True) reverses the stroke reveal each cycle.
Connections support .animate_draw() too:
d1 = cell.add_dot(at=(0.1, 0.42), radius=0.04, color="white")
d2 = cell.add_dot(at=(0.9, 0.42), radius=0.04, color="white")
conn = d1.connect(d2, curvature=0.4, color="skyblue", width=2)
conn.animate_draw(duration=2.0, easing="ease-in-out")
conn.loop(bounce=True)
Delayed draw
When using .animate_draw() with a delay, the stroke is fully hidden during the wait — then the draw begins on schedule. Pair it with a fade for a smooth entrance:
Easing¶
Easing controls the speed curve of an animation — whether it starts slow, ends slow, or moves at constant speed.
Pass a string name:
Available named easings:
| Name | Behavior |
|---|---|
"linear" |
Constant speed (default for most animations) |
"ease-in" |
Starts slow, accelerates |
"ease-out" |
Starts fast, decelerates |
"ease-in-out" |
Slow start and end (default for animate_draw and animate_move) |
You can also pass a custom cubic-bezier as a tuple:
Or use the Easing class directly:
from pyfreeform import Easing
dot.animate_fade(to=0.0, easing=Easing(0.68, -0.55, 0.27, 1.55))
dot.animate_fade(to=0.0, easing=Easing.EASE_IN_OUT)
Common Parameters¶
Every animation method accepts these parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
duration |
float |
1.0 |
Duration in seconds |
delay |
float |
0.0 |
Wait this many seconds before starting |
easing |
str \| tuple \| Easing |
"linear" |
Speed curve (see above) |
hold |
bool |
True |
Hold final value after animation ends |
To loop or bounce, call .loop() after setting up animations — see Looping below.
Method Chaining¶
All animate_* calls play simultaneously by default. Chain as many as you like — they all start at the same moment:
dot.animate_move(to=(0.85, 0.5), duration=2.0, easing="ease-in-out") \
.animate_fade(to=0.0, duration=2.0, easing="ease-in-out") \
.loop(bounce=True)
The dot slides right while fading to invisible — both animations run in parallel.
Sequential Chaining¶
Add .then() between animations to play them one after the other. The same two animations as above, but now sequenced:
dot.animate_move(to=(0.85, 0.5), duration=2.0, easing="ease-in-out") \
.then() \
.animate_fade(to=0.0, duration=2.0, easing="ease-in-out") \
.loop(bounce=True)
Now the dot slides to its destination fully visible, then fades where it landed. .loop(bounce=True) treats the whole sequence as a single unit — forward (slide → fade), then backward (re-appear → slide back), then forward again.
Chain bounce vs. per-animation bounce
Without .then(), loop(bounce=True) bounces each animation independently. With .then(), it bounces the whole sequence as one unit — the difference is what counts as "one cycle": a single animation, or the entire sequence.
Compare the two examples above: in the simultaneous version both animations restart together every 2 s; in the sequential version the whole 8 s journey (slide → fade → re-appear → slide back) forms one cycle.
Add a gap between animations:
dot.animate_fade(to=0.0, duration=1.0).then(0.5).animate_spin(360, duration=1.0)
# spin starts at 1.5s (1.0 fade + 0.5 gap)
Chain as many times as you like:
dot.animate_fade(to=0.5, duration=1.0).then().animate_spin(360, duration=2.0).then().animate_fade(to=1.0, duration=0.5)
.then() also works on connections:
Looping¶
There are two ways to loop animations in PyFreeform: per-animation (inline at creation time) and chain-level (via .loop()). They serve different use cases and work together.
Per-Animation Looping¶
Pass repeat= and bounce= directly to any animate_* call. This controls only that one animation, leaving others unaffected:
dot.animate_spin(360, duration=2.0) # plays once
dot.animate_color(to="blue", duration=1.0, repeat=True) # loops forever
dot.animate_radius(to=35, duration=1.2, repeat=True, bounce=True) # bounces forever
Here, spin plays once and stops, while color and radius loop independently. This is the standard pattern from GSAP, anime.js, Framer Motion, and CSS animations.
| Parameter | Type | Default | Description |
|---|---|---|---|
repeat |
bool \| int |
False |
False = play once, True = loop forever, int = play N times |
bounce |
bool |
False |
Alternate direction each cycle |
Chain-Level Looping with .loop()¶
Call .loop() after building your animation(s) to loop all animations on the entity at call time. It is a terminal method — it returns None.
dot.animate_fade(to=0.0, duration=1.5, easing="ease-in-out")
dot.loop() # loop forever
dot.loop(bounce=True) # loop, reversing direction each cycle
dot.loop(times=3) # loop exactly 3 times, then stop
.loop() is especially useful for .then() chains — it applies the same loop settings to all steps and makes the whole sequence loop as a unit:
cell.add_dot(at="center", radius=0.15, color="coral") \
.animate_fade(to=0.0, duration=1.5) \
.loop(bounce=True)
# The fade → spin sequence loops as one unit (bounce reverses the whole chain)
rect.animate_fade(to=0.3, duration=1.5).then().animate_spin(360, duration=2.0).loop(bounce=True)
.loop() parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
bounce |
bool |
False |
Alternate direction each cycle (forward → backward → forward…) |
times |
bool \| int |
True |
True = infinite, int = play N times |
Parity note for bounce=True, times=N
When bouncing finitely, the final resting value depends on parity: odd N freezes at the end value, even N freezes at the start value (the last bounce cycle reverses back).
.loop() overrides inline repeat=
If you call .loop() after setting repeat=True on individual animations, .loop() wins — it stamps its own bounce and times onto all animations on the entity. Use one or the other, not both.
Stagger¶
Animate a group of entities with offset timing using stagger():
from pyfreeform import stagger
dots = []
for i in range(6):
dots.append(cell.add_dot(at=(0.1 + i * 0.15, 0.4), radius=0.08, color="coral"))
stagger(*dots, offset=0.3,
each=lambda d: d.animate_fade(to=0.0, duration=1.5, easing="ease-in-out").loop(bounce=True))
The each callback applies animation(s) to each entity, and stagger offsets the timing by offset seconds per entity. You can apply multiple animations and .loop() inside the callback:
stagger(*dots, offset=0.15, each=lambda d: d.animate_fade(to=0.0).animate_spin(360))
stagger(*dots, offset=0.3,
each=lambda d: d.animate_fade(to=0.0, duration=1.5).loop(bounce=True))
Move¶
Animate position with .animate_move(). Use to= for an absolute target, or by= for a relative offset:
dot.animate_move(to=(0.8, 0.5), duration=1.0) # move to position
dot.animate_move(by=(0.1, 0), duration=1.0) # shift right by 10%
Positions are relative coordinates — (0.0, 0.0) is the top-left and (1.0, 1.0) is the bottom-right of the containing surface.
Reactive Animation¶
When a Polygon references entities as vertices, or a Connection references entities as endpoints, those shapes automatically animate when the referenced entities are animated with .animate_move().
# Four dots in a cell — polygon tracks their positions
p1 = cell.add_dot(at=(0.1, 0.12), radius=0.02, color="white")
p2 = cell.add_dot(at=(0.1, 0.72), radius=0.02, color="white")
p3 = cell.add_dot(at=(0.42, 0.72), radius=0.02, color="white")
p4 = cell.add_dot(at=(0.42, 0.12), radius=0.02, color="white")
poly = Polygon([p1, p2, p3, p4], fill="mediumpurple", stroke="white",
stroke_width=1, opacity=0.7)
scene.place(poly)
# Move dots inward — polygon follows automatically
p1.animate_move(to=(0.2, 0.28), duration=2.0, easing="ease-in-out").loop(bounce=True)
p2.animate_move(to=(0.16, 0.58), duration=2.0, easing="ease-in-out").loop(bounce=True)
p3.animate_move(to=(0.48, 0.58), duration=2.0, easing="ease-in-out").loop(bounce=True)
p4.animate_move(to=(0.52, 0.28), duration=2.0, easing="ease-in-out").loop(bounce=True)
The polygon's shape animates to follow its vertices — no extra code needed.
Connections work the same way:
d1 = cell.add_dot(at=(0.62, 0.2), radius=0.03, color="coral")
d2 = cell.add_dot(at=(0.92, 0.7), radius=0.03, color="gold")
conn = d1.connect(d2, color="skyblue", width=2)
d1.animate_move(to=(0.75, 0.7), duration=2.5, easing="ease-in-out").loop(bounce=True)
d2.animate_move(to=(0.8, 0.18), duration=2.5, easing="ease-in-out").loop(bounce=True)
# conn follows both endpoints automatically — both straight lines and curves
Mixed timing
Vertices and endpoints can have different durations, easings, delays, and even different .loop() settings — each vertex follows its own timing schedule. When timings differ, PyFreeform resamples all animations onto a unified timeline so the shape morphs smoothly.
Typed Property Methods¶
Every animatable property has its own method on the relevant entity type — IDE autocomplete shows exactly what you can animate:
dot = cell.add_dot(at="center", radius=0.06, color="coral")
dot.animate_radius(to=35, duration=1.2, easing="ease-in-out")
dot.loop(bounce=True)
More examples:
rect.animate_fill(to="coral", duration=2.0) # color transition
rect.animate_width(keyframes={0: 100, 1: 200, 2: 100})
rect.loop() # loop the keyframe sequence
The keyframes dict maps times (seconds) to property values at those times.
You can also pass a list — values are distributed evenly over the duration:
rect.animate_fill(keyframes=["coral", "dodgerblue", "coral"], duration=3.0)
rect.loop()
# equivalent to keyframes={0: "coral", 1.5: "dodgerblue", 3.0: "coral"}
Generic animate()¶
For rare properties without a typed method, the generic .animate() is still available as an escape hatch:
Renderers¶
By default, scene.save() and scene.to_svg() auto-detect animations. If any entity has animations, the output includes SMIL <animate> elements. If none do, the SVG is identical to static output.
You can force a specific renderer:
from pyfreeform.renderers import SVGRenderer, SMILRenderer
# Force static SVG (ignore all animations)
scene.render(SVGRenderer())
# Force animated SVG (default behavior)
scene.render(SMILRenderer())
Transform Origin¶
By default, animate_spin and animate_scale pivot around the entity's natural center (Rect center, Polygon centroid, etc.). The pivot= parameter lets you specify any other point as the rotation/scale origin, expressed as surface-relative fractions — the same coordinate system as at=.
Orbit: placing planets that revolve around a sun:
sun_rx, sun_ry = 0.5, 0.45
cell.add_dot(at=(sun_rx, sun_ry), radius=0.09, color="gold")
cell.add_dot(at=(sun_rx + 0.18, sun_ry), radius=0.03, color="coral") \
.animate_spin(360, duration=3.0, pivot=(sun_rx, sun_ry), repeat=True)
cell.add_dot(at=(sun_rx + 0.27, sun_ry), radius=0.025, color="skyblue") \
.animate_spin(360, duration=5.0, pivot=(sun_rx, sun_ry), repeat=True)
cell.add_dot(at=(sun_rx + 0.38, sun_ry), radius=0.02, color="limegreen") \
.animate_spin(360, duration=8.0, pivot=(sun_rx, sun_ry), repeat=True)
pivot=(0.5, 0.45), the sun's surface-relative position.Clock hand: a line spinning from its own start point:
# Line placed at start=(0.3, 0.5) — reuse the same coords as pivot
line = cell.add_line(start=(0.3, 0.5), end=(0.7, 0.5), width=3, color="white")
line.animate_spin(360, duration=5.0, pivot=(0.3, 0.5), repeat=True)
Scale from a corner instead of the center:
rect = cell.add_rect(at=(0.5, 0.5), width=0.4, height=0.4, fill="mediumpurple")
rect.animate_scale(2.0, duration=1.5, pivot=(0.3, 0.3), bounce=True, repeat=True)
SVG limitation: pivot does not follow a moving element
The pivot coordinates are baked into the SVG as fixed world-space values at render time.
If the entity is also animated with animate_move or animate_follow, the pivot will
stay pinned to its original location as the element moves away — this is a fundamental
constraint of SVG SMIL (and CSS transforms). The same limitation exists in anime.js and GSAP.
pivot= works correctly for stationary elements.
Showcase¶
Here's what happens when you combine multiple animation types in one scene — spinning, fading, pulsing, and self-drawing all playing together:
See also
For the full animation and renderer API, see Animation & Rendering.
What's Next?¶
You've completed the Guide! Put your skills to work with self-contained projects:
Or explore the complete API reference: