Advanced animation

The animation guide covers the primitives. This one composes them the way the animation engine example does — a full landing page where almost everything is declarative SSR and a single island owns the imperative timeline.

See it live: animation engine · ScrollSmoother.

The split: declarative vs imperative

Reach for the declarative path (.animate(), .scrollTrigger, .splitText, .draggable) whenever the animation is a property of the markup — entrances, scroll reactions, hovers. It ships zero WASM: the descriptor is serialized into the page and the bridge interpreter runs it.

Reach for an island only when you need to drive a timeline at runtime — play/pause/reverse/speed, FLIP reordering, or reading live scroll progress. One stateful chunk per page (the island memory rule), so keep the imperative surface small and let everything else stay declarative.

Orchestrating a page entrance

Stagger groups read top-to-bottom by composing delay with per-target stagger, all gated on a single scroll trigger so nothing plays above the fold until it scrolls in:

hero.animate(anim.from(a, ".hero-line")
    .opacity(0).y(28).stagger(.{ .each = 0.06 })
    .duration(0.7).ease(.out_cubic)
    .reducedMotion(.skip));            // respect prefers-reduced-motion

deck.animate(anim.from(a, ".card")
    .opacity(0).y(40).scale(0.96).stagger(.{ .each = 0.07 })
    .scrollTrigger(.{
        .start = .{ .viewport = .{ .pct = 80 } },
        .actions = .{ .on_enter = .play, .on_leave_back = .reverse },
    }));

on_leave_back = .reverse makes the section re-animate when scrolled back up — cheap polish that costs one extra enum.

Scrubbed, pinned, snapped sections

A scrubbed trigger ties progress to scroll position instead of time; pin it to hold the panel while the timeline plays, and snap to land on keyframes:

panel.animate(anim.to(a, ".panel-inner")
    .x(-600).rotate(8)
    .scrollTrigger(.{
        .start = .{ .trigger = .top, .viewport = .{ .pct = 20 } },
        .end = .{ .rel_vh = 1.5 },
        .scrub = .{ .smooth = 0.3 },        // eased; .exact = locked to scroll
        .pin = .self,                       // transform-pin (see below)
        .snap = .{ .points = &.{ 0, 0.5, 1 } },
    }));

Why .pin = .self and not position: fixed: ScrollSmoother runs the page inside a transformed wrapper, and position: fixed is relative to the nearest transformed ancestor — so it breaks. .pin = .self counter- translates the element instead, which is correct inside transformed content.

Container scrollers — a trigger can watch a scrollable element instead of the window. .scroller = "#feed" computes trigger geometry relative to that container's scroll position and client rect. v1 constraint: snap stays window-scoped and pin still uses position:fixed — neither composes with container scrollers yet.

Snap ease + directional bias.snap_ease overrides the glide curve (default .out_cubic; accepts all 31 types.Ease values). .snap_directional = true biases the snap target toward the scroll travel direction instead of nearest; falls back to nearest when no target exists in that direction.

SplitText mechanics

.splitText(.{ .by = .chars }) splits server-side — pure Zig node-tree surgery, zero JS for chars and words. lines needs the browser (wrap depends on layout): the bridge groups word spans into .st-line blocks by offsetTop once at hydrate, so .animate(anim.from(a, ".st-line")...) resolves correctly before any animation runs.

Grapheme clustering (By.graphemes) — default .chars splits per UTF-8 codepoint, so combining marks and ZWJ emoji sequences split apart. .by = .graphemes applies UAX#29 extended-grapheme-cluster boundaries entirely in Zig (via grapheme.zig + the cluster table) — no JS, no font loading. Emoji families, skin-tone modifiers, regional-indicator flags, and Hangul syllables stay whole.

RTL-aware splitting.rtl_aware = true detects consecutive codepoints of the same strong bidi direction and groups them into runs. RTL runs are wrapped in <span dir="rtl"> so the browser reorders glyphs within each run correctly. data-st-i indices remain logical-order dense across the full text (the attribute counts graphemes/chars in reading order, not visual order). Set dir="auto" on the host element for predominantly-RTL text. Deferred: full UAX#9 bidi reordering across runs (interleaved LTR/RTL tokens within a word remain the browser's responsibility).

FLIP counter-scale

When scale = true animates a container's size as scaleX/Y, child content distorts. Set counter_scale = true to apply the inverse scale to each immediate child every tick — content stays crisp throughout the FLIP:

_ = verve.flipPlay(state, .{
    .scale         = true,
    .counter_scale = true,   // 1/scaleX · 1/scaleY on each child per frame
    .duration      = 0.5,
    .ease          = .in_out_sine,
}, .{});

Caveat: child transforms are cleared on finish — any pre-existing child transform is not restored. .counter_scale only has effect when .scale is also true.

Paths and morphs from real geometry

MotionPath and MorphSVG take raw SVG path d strings — and verve.viz's edgePathD emits exactly that, so a node can ride an edge computed server-side:

marker.animate(anim.to(a, ".marker")
    .motionPath(.{ .path = viz_edge_d, .rotate = true })
    .duration(4).repeat(-1));

shape.animate(anim.to(a, "#blob")
    .morph(.{ .from = star_d, .to = blob_d })
    .yoyo(true).repeat(-1));

The imperative island

When you do cross into an island, the chunk builds a timeline against live elements and exposes controls as exports. The animation example's island exports anim_play / anim_pause / anim_reverse, speed toggles (anim_full_speed, anim_half_speed), anim_restart, plus a FLIP keyed-shuffle (anim_shuffle) and a card flip (anim_flip_card_toggle) — each wired to a z-on-click:

ctx.el("button").attr("z-on-click", "anim_pause").text("Pause"),
ctx.el("button").attr("z-on-click", "anim_shuffle").text("Shuffle"),

FLIP in three beats: capture the current rects (flipCapture), mutate the DOM through listDiff (the keyed reconciler — never innerHTML), then flipPlay to tween from old rects to new. Because the diff is keyed, moved nodes keep identity and animate; only genuinely new nodes fade in.

Sortable — cross-list groups

Cross-list transfer fires on_enter_group on both the source and target containers of a move — not just the one the item landed in. Disambiguate via SortableHandle:

const h: verve.SortableHandle = .{ .id = handle_id };
const from_c = h.fromContainer(); // source container ref-handle; -1 if same list
const to_c   = h.toContainer();   // target container ref-handle; -1 if same list

lastFrom() and lastTo() give slot indices before/after the move within whichever container the item now lives in. All of this is island-only (callback slots are rejected at SSR). The FLIP sibling animation respects prefers-reduced-motion (reorder is instant, no animation plays).

See sortable demo for a live two-list board.

Budget

Every declarative tween is free of WASM, but each island costs a chunk and the page allows one stateful chunk. Compose: declarative entrances + scroll triggers everywhere, one island for the timeline you actually need to control. See building a larger feature for composing animation with other subsystems on one page.

Next: Building a larger feature.