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