Dancing Circles

This tutorial builds an animated radial composition from repeated circles. Each circle starts on a ring, grows toward its own radius, moves to the center, and returns to its starting point.

Imports and Theme

The composition uses math for radial coordinates and Theme for Tailwind inspired default colors.

from math import cos, radians, sin

import pydreamplet as dp

theme = dp.Theme()

Canvas and Layout Values

The SVG is square. The largest possible circle radius is based on half of the canvas width minus a small margin.

svg = dp.SVG(1024, 1024)
margin = 24
min_radius = 10
circle_count = 32

max_radius = svg.w / 2 - margin
radius_step = (max_radius - min_radius) / (circle_count - 1)

circle_count controls both the number of circles and the angular spacing. A higher value makes the ring denser; a lower value makes the individual motion easier to read.

Blended Stroke Colors

Create one color for each circle by blending between the theme's pink and sky tokens. blend() accepts theme colors directly, including OKLCH values.

colors = [
    dp.blend(theme.pink, theme.sky, i / circle_count) for i in range(circle_count)
]

dp.blend_colors() is also available when you prefer the more explicit name.

Centered Group

Instead of adding half of the canvas width and height to every circle, place a group in the center of the SVG. Every circle can then use coordinates relative to that center.

g = dp.G(pos=(svg.w / 2, svg.h / 2))
svg.append(g)

The group will receive its rotation animation after the circles are appended, so the whole composition rotates as one element.

Circle Positions

Divide the full circle into equal angles. For each index, compute the angle, the circle's final radius, and the starting position on the outer ring.

angle_step = 360 / circle_count

for i in range(circle_count):
    angle = radians(i * angle_step)
    r = min_radius + i * radius_step
    final_cx = max_radius * cos(angle)
    final_cy = max_radius * sin(angle)

cos() gives the x coordinate and sin() gives the y coordinate. Multiplying both by max_radius places the circle on the outer ring.

Animated Circles

Each circle starts with the same small radius. The stroke color comes from the blended palette, and the fill is transparent.

for i in range(circle_count):
    angle = radians(i * angle_step)
    r = min_radius + i * radius_step
    final_cx = max_radius * cos(angle)
    final_cy = max_radius * sin(angle)

    circle = dp.Circle(
        pos=(final_cx, final_cy),
        r=min_radius,
        fill=theme.transparent,
        stroke=colors[i],
    )

Add three animations: one for radius, one for cx, and one for cy. The radius grows and shrinks; the center point moves inward and back out.

for i in range(circle_count):
    angle = radians(i * angle_step)
    r = min_radius + i * radius_step
    final_cx = max_radius * cos(angle)
    final_cy = max_radius * sin(angle)
    circle = dp.Circle(pos=(final_cx, final_cy), r=min_radius)

    circle.append(dp.Animate("r", values=[min_radius, r, min_radius], dur="5s"))
    circle.append(dp.Animate("cx", values=[final_cx, 0, final_cx], dur="5s"))
    circle.append(dp.Animate("cy", values=[final_cy, 0, final_cy], dur="5s"))
    g.append(circle)

Rotating the Group

AnimateTransform creates SVG <animateTransform>, which is the correct SVG element for animating transform. Appending it to the group rotates every circle around the group's local origin. Because the group is positioned at the canvas center, the whole ring rotates around the middle of the SVG.

g.append(
    dp.AnimateTransform(
        "rotate",
        values=[0, 120, 240, 360],
        dur="10s",
        calcMode="linear",
        additive="sum",
    )
)

values defines the rotation keyframes. calcMode="linear" keeps the angular speed even, and additive="sum" lets the animation add to the group's existing transform instead of replacing it.

Complete Script

from math import cos, radians, sin

import pydreamplet as dp

theme = dp.Theme()

svg = dp.SVG(1024, 1024)
margin = 24
min_radius = 10
circle_count = 32
max_radius = svg.w / 2 - margin
radius_step = (max_radius - min_radius) / (circle_count - 1)
colors = [
    dp.blend(theme.pink, theme.sky, i / circle_count) for i in range(circle_count)
]
g = dp.G(pos=(svg.w / 2, svg.h / 2))
svg.append(g)

angle_step = 360 / circle_count

for i in range(circle_count):
    angle = radians(i * angle_step)
    r = min_radius + i * radius_step
    final_cx = max_radius * cos(angle)
    final_cy = max_radius * sin(angle)

    circle = dp.Circle(
        pos=(final_cx, final_cy),
        r=min_radius,
        fill=theme.transparent,
        stroke=colors[i],
    )
    circle.append(dp.Animate("r", values=[min_radius, r, min_radius], dur="5s"))
    circle.append(dp.Animate("cx", values=[final_cx, 0, final_cx], dur="5s"))
    circle.append(dp.Animate("cy", values=[final_cy, 0, final_cy], dur="5s"))
    g.append(circle)

g.append(
    dp.AnimateTransform(
        "rotate",
        values=[0, 120, 240, 360],
        dur="10s",
        calcMode="linear",
        additive="sum",
    )
)

svg.save("output/dancing_circles.svg")