Skip to content

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 animation

A coral dot pulsing between visible and invisible.

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()

Spin animation

A rectangle spinning continuously — `.loop()` makes it spin forever.

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)

Scale animation

A dot pulsing between normal size and 2× — `.loop(bounce=True)` reverses each cycle.

.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.

Draw animation

A wave drawing and undrawing itself in a loop.

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)

Connection animation

A curved connection drawing and undrawing itself between two dots.

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:

conn = d1.connect(d2, color="skyblue", width=2, opacity=0.0)
conn.animate_fade(to=1.0, duration=0.15, delay=2.0, hold=True)
conn.animate_draw(duration=1.0, delay=2.0)

Easing

Easing controls the speed curve of an animation — whether it starts slow, ends slow, or moves at constant speed.

Pass a string name:

dot.animate_fade(to=0.0, duration=2.0, easing="ease-in-out")

Easing comparison

Four dots racing across the same distance — watch how their speeds differ.

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:

dot.animate_fade(to=0.0, easing=(0.68, -0.55, 0.27, 1.55))  # overshoot

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.

Chaining

A dot sliding across while fading — move and fade play at the same time.

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.

Then chaining

A dot sliding right then fading out, then rewinding in reverse — the whole 4-step sequence loops as one unit.

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:

conn.animate_draw(duration=1.5).then().animate_fade(to=0.0, duration=0.5)

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))

Stagger animation

Six dots fading out one by one — each starts 0.3 seconds after the last.

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

Reactive animation

Left: polygon vertices react to moving dots. Right: connection follows its endpoints.

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)

Typed animate

A dot pulsing in size — `.loop(bounce=True)` reverses each cycle.

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:

dot.animate("custom_attr", to=42, duration=1.0)

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

Three planets orbiting a sun — each uses 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:

Showcase

A combined scene: self-drawing connection, spinning rect with fade, pulsing dot, fading dot, self-drawing paths, and a row of spinning squares.

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:

Browse Recipes →

Or explore the complete API reference:

API Reference →

← Gradients