Skip to content

Positioning

Every node has an x / y position relative to its parent container. Three methods cover most placement needs: .xy() for absolute coordinates, .align() for proportional placement within the parent, and .move() for small relative offsets on top of any of the above. .z() covers the third axis — which node paints in front of which.


Absolute position — .xy(x, y)

.xy(x, y) sets the node's position in pixels relative to the top-left corner of its parent:

with Scene():
    Rect().size(60, 60).fill("tomato").xy(20, 20)
    Rect().size(60, 60).fill("gold").xy(120, 70)
    Rect().size(60, 60).fill("mediumseagreen").xy(220, 120)

Output image

fairyflow frame 0

Relative position — rel(f)

rel(f) is a value marker accepted anywhere .xy() takes a coordinate (and by .size(), see Shapes): it means f times the parent's corresponding dimension — width in the x slot, height in the y slot. It resolves against the node's parent — the nearest Group(), or the Scene itself when the node has no intermediate group:

with Scene(width=300, height=160):
    Rect().size(60, 60).fill("tomato").xy(x=rel(0.5) - 30, y=rel(0.5) - 30)

Output image

fairyflow frame 0

rel(f) is an Expr, so it composes with arithmetic (rel(1) - 20, rel(0.5) + 10) — useful when you need a proportional position offset by a fixed pixel amount. Unlike .align(), whose 0.0–1.0 factor already accounts for the node's own size, rel(f) is a raw fraction of the parent's box with no such adjustment.


Proportional alignment — .align()

.align(x=fx, y=fy) places the node relative to the container size: 0.0 = left / top edge, 0.5 = center, 1.0 = right / bottom edge. Either axis can be omitted to leave it untouched.

with Scene():
    Rect().size(60, 60).fill("tomato").align(0, 0)
    Rect().size(60, 60).fill("gold").align(0.5, 0.5)
    Rect().size(60, 60).fill("mediumseagreen").align(1, 1)

Output image

fairyflow frame 0

.align() composes freely with .xy() — you can align on one axis and set the other directly:

with Scene():
    Rect().size(80, 50).fill("steelblue").align(x=0.5).xy(y=30)
    Rect().size(80, 50).fill("coral").align(x=0.5).y(110)

Output image

fairyflow frame 0

Relative offset — .move(dx, dy)

.move(dx, dy) shifts a node by a fixed pixel offset on top of its already-computed position, regardless of how that position was set:

with Scene():
    Rect().size(60, 60).fill("orchid").align(0.5, 0.5).move(-80, 0)
    Rect().size(60, 60).fill("steelblue").align(0.5, 0.5)
    Rect().size(60, 60).fill("gold").align(0.5, 0.5).move(80, 0)

Output image

fairyflow frame 0

Depth — .z(level)

.z(level, dur=, ease=) places a node on the depth axis: higher z paints on top, regardless of creation order or of which group a node sits in. Ordering is global to the scene, not per-group. It is available on every drawable node except text runs (TextGroup/TextSpan), where paragraph order is always the draw order. Nodes with equal z paint in creation order.

with Scene():
    Rect().size(100, 100).fill("steelblue").xy(20, 20)
    Rect().size(100, 100).fill("tomato").xy(60, 50).z(-1)

Output image

fairyflow frame 0

Here the tomato rect is created after the blue one (so it would normally paint on top), but z(-1) sends it behind instead. .z() takes dur=/ease= like any other setter, so a node can be brought to the front or sent to the back partway through a scene.

Groups and inherited z

A node that never calls .z() inherits its parent's level (the Scene's own 0 at the top). A group's contents therefore stay together by default, and .z() on a group lifts or sinks everything inside it:

with Scene():
    with Group().size(120, 80).xy(20, 20):
        Rect().size(120, 80).fill("steelblue")
    with Group().size(120, 80).xy(80, 60).z(-1):
        Rect().size(120, 80).fill("tomato")   # inherits z = -1, so the
                                              # whole group goes behind

Output image

fairyflow frame 0

Because ordering is global rather than per-group, an explicit .z() deep in the tree outranks nodes in other groups too — which is exactly what you want for a connector that overhangs into the next row, and is worth knowing about when a group holds its own backdrop:

with Scene():
    with Group().size(120, 80).xy(20, 20):
        Text("one")
        Rect().size(120, 80).fill("gainsboro").z(-1)   # sinks below the *scene*,
    with Group().size(120, 80).xy(80, 60):             # not just below "one"
        Text("two")
        Rect().size(120, 80).fill("lightsteelblue").z(-1)

Output image

fairyflow frame 0

Both backdrops paint first, then both labels — so the second card's backdrop no longer covers the first card's text. To make cards stack as whole units, give each group its own band and place its backdrop just under that band rather than at a global -1: the first group at z(0) with its backdrop at z(-1), the second at z(10) with its backdrop at z(9).


Cross-group positioning — at() and pos()

The placement methods above always work within the coordinate space of a single parent. When you need to position a node relative to another node that lives in a different group, use .at() and .pos().

.at(x, y) returns a Position object — a live coordinate reference attached to the source node's coordinate space. Fractions must be given in pairs; a bare at(0.5) raises TypeError rather than silently meaning top-center. Nine named anchors cover the common cases:

call point returned
node.at() center of the node (the default — also the only point for a size-less node)
node.at("center") center (same as bare at())
node.at("top") top-center edge
node.at("bottom") bottom-center edge
node.at("left") left-center edge
node.at("right") right-center edge
node.at("top_left") top-left corner
node.at("top_right") top-right corner
node.at("bottom_left") bottom-left corner
node.at("bottom_right") bottom-right corner
node.at(0.2, 0.7) 20% across, 70% down

.pos(position) sets the node's position to a Position returned by .at(). FairyFlow automatically converts the coordinates into the target node's local space, so group offsets, scales, and rotations are all accounted for — no manual maths needed. This works between any two nodes, whether they live inside a Group() or directly in the bare Scene.

Connecting nodes across groups

A common use-case is drawing a line between two nodes that live in separate groups:

with Scene(width=300, height=160):
    with Group().size(60, 60).xy(30, 50) as a:
        Rect().size(60, 60).fill("steelblue")
    with Group().size(60, 60).xy(210, 50) as b:
        Rect().size(60, 60).fill("coral")

    connector = Path().stroke("#555", 2)
    connector.move_to(a.at("right"))   # right-center of a
    connector.line_to(b.at("left"))    # left-center of b

Output image

fairyflow frame 0

Tracking during animation

Because .at() returns a live reference to the node's attribute expressions (not a snapshot of the current value), a node set with .pos() tracks its source at every frame. In the example below, one end of the line is fixed on a static anchor while the other end is permanently bound to the moving box's center:

with Scene(width=300, height=160):
    anchor = Ellipse().size(12, 12).fill("tomato").xy(100, 20)    

    with Group().size(50, 50).xy(20, 55) as box:
        Rect().size(50, 50).fill("steelblue")

    line = Path().stroke("#888", 2)
    line.move_to(anchor.at())  # fixed end
    line.line_to(box.at())     # tracks box center (the default anchor)

    box.xy(180, 55, dur=1.5)   # move the box — the line stretches automatically

Output video

Fine-tuning with .move()

.pos() returns self, so .move() can be chained directly to nudge the result by a fixed offset:

with Scene(width=300, height=160):
    with Group().size(60, 60).align(0.5, 0.5) as box:
        Rect().size(60, 60).fill("steelblue")

    # arrow tip sits 10 px above the top-center of box
    arrow = Path().stroke("tomato", 3)
    arrow.move_to(box.at("top")).move(0, -30)
    arrow.line_to(box.at("top")).move(0, -4)
    arrow.arrow("end")

Output image

fairyflow frame 0

Sibling placement — .next_to()

.at()/.pos() place a node at a point; .next_to(node, direction, gap=0, align=0.5) places it beside another node, taking both boxes' size into account — the everyday "label next to box" case that would otherwise need manual offset math:

with Scene(width=300, height=120):
    with Group().size(60, 60).xy(40, 30) as box:
        Rect().size(60, 60).fill("steelblue")

    label = Text()
    label.span("label").font(size=16)
    label.next_to(box, "right", gap=12)   # right of box, vertically centered

Output image

fairyflow frame 0

direction is one of "right", "left", "above", "below" — which side of the target to place on. gap is the pixel distance between the facing edges. align places the node along the perpendicular axis: 0 start-aligned, 0.5 centered (the default), 1 end-aligned:

with Scene(width=300, height=120):
    with Group().size(60, 60).xy(40, 20) as img:
        Rect().size(60, 60).fill("coral")

    caption = Text()
    caption.span("caption").font(size=14)
    caption.next_to(img, "below", gap=8, align=0)   # under img, left edges aligned

Output image

fairyflow frame 0

Like .pos(), next_to() works across groups and is live — built on the same node_transform machinery, so the node keeps tracking its target as it moves. It also accepts dur=/ease= like any other position change.


Following a path — .follow_path()

.follow_path(path, dur=1, start=0, end=1) animates a node along a Path over the given duration. The node travels from start to end (both are path parameters in the range 0–1, where 0 is the path's start and 1 is its end), centered on the curve at every frame. The clock advances automatically by dur seconds.

Any Path shape works as the track — straight lines, multi-segment paths, or Bézier curves. Use cubic_to(x, y, c1=, c2=) to add a cubic Bézier segment; c1/c2 are (dx, dy) offsets relative to the segment's start and end points respectively:

with Scene(width=300, height=200):
    # The track: an arch-shaped cubic Bézier
    track = Path().stroke("#bbb", 2)
    track.move_to(30, 160)
    track.cubic_to(270, 160, c1=(60, -130), c2=(-60, -130))
    # c1: pulls up from the start; c2: pulls up into the end

    # A ball that travels along the arch
    ball = Ellipse().size(22, 22).fill("steelblue")
    ball.follow_path(track, dur=2)

Output video

Pass start=1, end=0 to travel in the opposite direction — from the end of the path to the start. This is useful for animating a return trip or for reversing entrance effects:

with Scene(width=300, height=200):
    track = Path().stroke("#bbb", 2)
    track.move_to(30, 160)
    track.cubic_to(270, 160, c1=(60, -130), c2=(-60, -130))

    with Par():
        Ellipse().size(22, 22).fill("steelblue").follow_path(track, dur=2)
        Ellipse().size(22, 22).fill("coral").follow_path(track, dur=2, start=1, end=0)

Output video

The same path can be used to animate multiple nodes. Nesting Seq inside Par creates a staggered procession where each traveller starts slightly after the previous one:

with Scene(width=300, height=200):
    track = Path().stroke("#bbb", 2)
    track.move_to(30, 160)
    track.cubic_to(270, 160, c1=(60, -130), c2=(-60, -130))

    colors = ["steelblue", "coral", "gold"]
    with Par():
        for i, color in enumerate(colors):
            with Seq():
                wait(0.4 * i)
                Ellipse().size(22, 22).fill(color).follow_path(track, dur=2)

Output video