Animations
An animation in kinemo is an immutable value with a duration, an easing and a target.
It does nothing until you pass it to s.play or s.start. There are two families:
verbs, module functions such as k.draw and k.morph that bring objects in and out or
add emphasis, and state changes, obj.to(...). This guide tours both and shows how to
combine them.
Reference: verbs (for example k.morph),
composition (k.ease),
text.
Verbs at a glance#
| Verb | Category | What it does |
|---|---|---|
k.draw(*objs) |
Entry | Traces the outline, then fills |
k.write(*objs) |
Entry | Writes text character by character; other objects are drawn |
k.fade_in(*objs, shift=) |
Entry | Opacity 0 → 1, optionally arriving from an offset |
k.grow(obj, from_=) |
Entry | Scales up from the center or a side |
k.fade_out(*objs, shift=) |
Exit | Opacity 1 → 0, then leaves the scene |
k.shrink(obj, to=) |
Exit | The inverse of k.grow, then leaves the scene |
k.morph(a, b, match=) |
Swap | a leaves, b enters, matching parts travel |
k.indicate(obj, color=, scale=) |
Emphasis | Temporary tint and pulse; ends as it started |
k.flash(obj, color=) |
Emphasis | A ring of light that expands from the edge |
k.squash(obj, amount=) |
Emphasis | Elastic squash against the base |
k.follow(obj, path, rotate=) |
Motion | Travels along a path |
k.sound(path, gain=) |
Audio | Plays a sound file at that instant |
Every verb and every .to() lasts 1 s with k.ease.smooth and accepts duration=,
ease= and delay=.
Entrances and exits#
import kinemo as k
@k.scene
def entrances(s: k.Scene):
title = k.Text("Entrances", size=0.7).place(at="top", margin=0.8)
circle = k.Circle(r=0.8, fill=k.BLUE, fill_opacity=0.4)
bar = k.Bar(5, label=True)
card = k.RoundedRect(w=2, h=1.4)
k.Row(circle, bar, card, gap=1, align="bottom").place(at="center")
s.play(k.write(title))
s.play(k.draw(circle))
s.play(k.grow(bar, from_="bottom"))
s.play(k.fade_in(card, shift=(0, 0.5))) # arrives from 0.5 u below
s.wait(0.5)
s.play(k.fade_out(circle, card), k.shrink(bar, to="bottom"))
s.play(k.fade_out(title, shift=(0, 0.5))) # leaves upwards
The row itself never entered the scene in that example, yet its children are drawn in
place: the container still lays them out. An entry verb on a group (k.draw(row)) brings in the
group and all of its children together.
When the object is a component that defines enter() or exit(), the
verbs use those implementations instead of the default behavior.
State changes with .to()#
obj.to(**props) animates any public prop from its value at the cursor to the target:
import kinemo as k
@k.scene
def state_changes(s: k.Scene):
square = k.Square(1.5, x=-3)
bar = k.Bar(2, label=True).place(at="right", margin=3)
s.add(square, bar)
s.play(square.to(x=0, rotate=45, color=k.RED, fill_opacity=0.4))
s.play(bar.to(value=6), square.to(scale=0.6), duration=1.5)
s.play(square.to(opacity=0.3, ease=k.ease.out))
How values interpolate:
| Type | Interpolation |
|---|---|
float, int, vectors |
Linear in easing space (int rounds) |
| Colors | In OKLab, so there is no gray in the middle |
| Paths and shapes | Point correspondence with resampling |
Strings in k.Text |
Glyph morph (see Text changes) |
bool, enums |
Step at the end |
| Lists and custom types | Need lerp= on the signal; otherwise K0205 |
Components expose named transitions that read better than a raw .to(): row.swap(i, j), row.insert(i, obj), row.pop(i), ax.zoom_to(x=..., y=...),
code.highlight(lines=[...]), chart.to(data=...). Each one is documented as sugar for a
.to() and behaves exactly like it, including in conflicts.
Easing#
k.ease.linear k.ease.smooth (default) k.ease.in_ k.ease.out k.ease.in_out
k.ease.out_back k.ease.out_elastic k.ease.spring(stiffness=100, damping=10)
k.ease.steps(n) k.ease.custom(fn) k.ease.reverse(e)
import kinemo as k
def decelerate(t: float) -> float:
return 1 - (1 - t) ** 3
@k.scene
def easing(s: k.Scene):
eases = [k.ease.linear, k.ease.smooth, k.ease.out_back, k.ease.spring(stiffness=120, damping=8),
k.ease.steps(5), k.ease.custom(decelerate)]
dots = [k.Dot(r=0.15, x=-5, y=2.5 - i) for i in range(len(eases))]
s.add(*dots)
s.wait(0.5)
s.play(*[d.to(x=5, ease=e) for d, e in zip(dots, eases)], duration=2)
s.wait(0.5)
ease= on s.play(..., ease=...) applies to the whole group. Use k.ease.linear for
anything that should move at constant speed: clocks, scans, travel along a path.
Morph#
k.morph(a, b) swaps one object for another: a leaves, b enters, and the parts they
share travel from one to the other while the rest fades out and in. It works for text,
formulas, code and shapes.
import kinemo as k
@k.scene
def morph_text(s: k.Scene):
a = k.Text("a + b = c", size=0.8).place(at="center")
b = k.Text("c = a + b", size=0.8).place(at="center")
s.play(k.write(a))
s.play(k.morph(a, b)) # the letters slide to their new places
s.wait(0.5)
Equations#
Between k.Math formulas, parts are matched in this order:
- your explicit
match=; - subexpressions named with
\id{name}{...}that share a name; - identical TeX subtrees, largest first (
c^2andc^{2}are the same); - identical glyphs, by relative position (this breaks ties between repeated terms);
- whatever is left fades out from
aand fades in tob.
import kinemo as k
@k.scene
def morph_math(s: k.Scene):
eq1 = k.Math(r"a^2 + b^2 = c^2").place(at="center")
eq2 = k.Math(r"c = \sqrt{a^2 + b^2}").place(at="center")
eq3 = k.Math(r"\id{len}{\sqrt{a^2 + b^2}} = c").place(at="center")
s.play(k.write(eq1))
s.play(k.morph(eq1, eq2)) # a^2 + b^2 and c travel
s.play(k.morph(eq2, eq3))
s.play(eq3["len"].to(color=k.YELLOW))
s.wait(0.5)
match= maps glyph keys of a (the characters or symbols you see) to glyph keys of b, to
force pieces that differ to travel into each other:
import kinemo as k
@k.scene
def morph_match(s: k.Scene):
before = k.Math(r"f(a) = a^2 + 1").place(at="center")
after = k.Math(r"f(x) = x^2 + 1").place(at="center")
s.play(k.write(before))
s.play(k.morph(before, after, match={"a": "x"})) # each a slides into an x
s.wait(0.5)
Code#
Between two k.Code blocks, the morph diffs lines and then tokens: identical lines slide,
inserted ones enter, removed ones leave.
import kinemo as k
V1 = """
def area(r):
return 3.14 * r * r
"""
V2 = """
import math
def area(r):
return math.pi * r ** 2
"""
@k.scene
def morph_code(s: k.Scene):
code = k.Code(V1, lang="python", line_numbers=True).place(at="center")
s.play(k.write(code))
s.play(code.highlight(lines=[2]))
s.play(k.morph(code, k.Code(V2, lang="python", line_numbers=True).place(at="center")))
s.wait(0.5)
With nothing in common (say, a circle into a word), the morph does a warp plus crossfade
and emits lint W0801.
Text changes#
Changing the string of a k.Text with .to(text=...) is a morph under the hood: shared
characters travel, the rest fades. Parts of a text are objects of their own, so you can
animate a word or a range of characters:
import kinemo as k
@k.scene
def text_changes(s: k.Scene):
txt = k.Text("Hello world, hello kinemo", size=0.6).place(at="center")
s.play(k.write(txt))
s.play(txt["world"].to(color=k.YELLOW)) # first occurrence
s.play(k.stagger([w.to(scale=1.2) for w in txt.words], lag=0.1))
s.play(*[p.to(color=k.TEAL) for p in txt.find_all("o")])
s.play(txt.to(text="Goodbye world"))
s.wait(0.5)
Parts: txt["world"] (first occurrence), txt.find_all("o"), txt.chars[3:7],
txt.words[1], txt.lines[0]. In formulas, eq["name"] from \id{name}{...} or a TeX
subexpression like eq["c^2"].
For text that changes continuously with a value (a counter, a clock), do not chain
.to(text=...): bind it with a lambda, k.Text(lambda: f"{x():.1f} kWh"). See
reactive values.
Emphasis#
import kinemo as k
@k.scene
def emphasis(s: k.Scene):
word = k.Text("important", size=0.8).place(at="top", margin=1.2)
dot = k.Dot(r=0.3).place(at="center")
ball = k.Circle(r=0.6, fill=k.ORANGE, fill_opacity=1).place(at="bottom", margin=1.2)
s.add(word, dot, ball)
s.play(k.indicate(word, color=k.YELLOW, scale=1.3))
s.play(k.flash(dot, color=k.YELLOW))
s.play(k.squash(ball, amount=0.4))
s.wait(0.5)
All three return the object to its initial state. k.indicate is reversible, so it also
works inside s.during(...) (it holds the highlight for the duration of the block).
Motion along a path: k.follow#
k.follow(obj, path) moves an object along the outline of another object or along a list
of points, in world coordinates. rotate=True keeps it aligned with the tangent.
import kinemo as k
@k.scene
def orbit(s: k.Scene):
track = k.Circle(r=2.5, stroke=k.GRAY).place(at="center")
ship = k.Triangle(scale=0.25, fill=k.BLUE, fill_opacity=1)
s.add(track, ship)
s.play(k.follow(ship, track, rotate=True), duration=3, ease=k.ease.linear)
ship.unbind("x", "y", "rotate") # free it before moving it again
s.play(k.follow(ship, [(2.5, 0), (0, -3), (-3, 0)]), duration=2)
k.follow drives x, y (and rotate) through bindings that stay in place after it
ends, so call obj.unbind("x", "y", "rotate") before animating the position again,
including with a second k.follow. It only moves objects that have no parent, and not
objects pinned by .place(...).
Composition patterns#
Animations are values, so ordinary Python composes them.
One animation per item, staggered.
import kinemo as k
@k.scene
def cascade(s: k.Scene):
cards = [k.RoundedRect(w=1.6, h=1) for _ in range(6)]
grid = k.Grid(*cards, cols=3, gap=0.3).place(at="center")
s.play(k.stagger([k.draw(c) for c in cards], lag=0.1))
s.play(k.stagger([c.to(color=k.TEAL) for c in grid], lag=0.05, order="random(1)"))
s.wait(0.5)
Build a step once, reuse it with variations.
import kinemo as k
@k.scene
def variations(s: k.Scene):
dot = k.Dot(r=0.25, x=-4)
s.add(dot)
move = dot.to(x=4)
s.play(move.with_(duration=2, ease=k.ease.out_back))
s.play(dot.to(x=-4).with_(ease=k.ease.linear, delay=0.2))
Enter a whole diagram in a choreographed order.
import kinemo as k
@k.scene
def choreography(s: k.Scene):
a = k.RoundedRect(w=2, h=1.2).place(at="left", margin=2.5)
b = k.RoundedRect(w=2, h=1.2).place(at="right", margin=2.5)
la = k.Text("client", size=0.4).place(inside=a)
lb = k.Text("server", size=0.4).place(inside=b)
link = k.Arrow(start=a.edge("right"), end=b.edge("left"))
s.play(k.seq(k.par(k.draw(a), k.draw(b)), k.par(k.write(la), k.write(lb)), k.grow(link, from_="left")),
duration=2.5)
s.wait(0.5)
Sum two motions on purpose with blend="add". A shake on top of a movement writes the
same prop at the same time, which is normally a conflict (K0201). blend="add" adds the
animation to whatever else is writing that prop:
import kinemo as k
@k.scene
def shake(s: k.Scene):
box = k.Square(1.2, x=-4)
s.add(box)
jitter = k.seq(*[box.to(x=0.15 * (-1) ** i, blend="add", duration=0.1) for i in range(8)])
s.play(box.to(x=4, duration=2), jitter.with_(delay=0.6)) # both write box.x
s.wait(0.5)
Temporary state while something else happens: with s.during(...), and reusable
multi-step sequences: @k.clip. Both are covered in the timeline guide.
Common mistakes
You see Why Fix K0201 two animations write c.x at the same timeTwo animations on the same prop overlap. k.seqthem, orblend="add"when summing is the intent.K0203 not an animations.play(obj)ors.play([a, b]).s.play(k.draw(obj)); unpack lists:s.play(*anims)ors.play(k.stagger(anims)).K0204 ... is not reversible and cannot be used in s.duringAn entrance/exit verb inside s.during.Only .to(...)andk.indicatethere.K0205 this list does not know how to interpolate.to()on a list signal withoutlerp=.k.signal([...], lerp=k.lerp.pointwise)orlerp=None(step).K0401 ... held by a reactive bindingafterk.followk.followleavesx/ybound to the path.obj.unbind("x", "y", "rotate")before moving it again.K0401 ... held by a constraint.to(x=...)on a placed object..to_place(...), or.to(x=..., unpin=True).K0404 unknown anchorA typo in from_=/to=ofk.grow/k.shrink.Use center,top,bottom,left,rightor a corner liketop-left.W0801 morph without matchesMorphing two things with nothing in common. Expected for shape swaps; otherwise give match=or use\id{...}names.K0801 unsupported LaTeX commandText-mode or package commands (TikZ...) in k.Math.Keep k.Mathto math; put prose ink.Text.K1101/K1102Manim names: Create,x.animate.shift(UP).k.draw(x),s.play(x.to(y=x.y.now + 1)).