Animation

verve.anim builds animation descriptors in Zig, serializes them into the page, and lets the bridge interpreter run them — declaratively with zero WASM, or imperatively from an island.

See it live: animation engine and ScrollSmoother.

Declarative — .animate() on a node

ctx.h1("verve.anim").animate(anim.from(a, null)
    .opacity(0).y(24)
    .duration(0.6).ease(.out_cubic)),

anim.from animates from the given values to the rendered state; anim.to the reverse. Chain properties (opacity x y scale scaleX rotate …), timing (duration delay ease repeat yoyo), stagger(.{ .each = 0.06 }) for multi-target selectors, and reducedMotion(.skip) to respect prefers-reduced-motion.

Multi-step keyframes:

.animate(anim.to(a, null)
    .step(0).scale(1.0)
    .step(50).stepEase(.in_out_sine).scale(1.3)
    .step(100).scale(1.0)
    .duration(1.4).repeat(-1))

Scroll triggers

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

Scrubbing, pinning, and snap:

.scrollTrigger(.{
    .start = .{ .trigger = .top, .viewport = .{ .pct = 20 } },
    .end = .{ .rel_vh = 1.5 },
    .scrub = .{ .smooth = 0.3 },   // or .exact
    .pin = .self,                  // transform-pinned panel
    .snap = .{ .step = 1.0 / 3.0 },// or .{ .points = &.{0, 0.5, 1} }
    .markers = true,               // debug overlay
})

anim.reveal(a, "class-name", .{ .once = true, … }) is the zero-wasm class-toggle variant.

Container scrollers — watch a scrollable element instead of the window. Set .scroller to a CSS selector (or .scroller_handle to an island ref-handle) and trigger geometry is computed against the container's scroll position and client rect:

ctx.article().class("post")
    .animate(anim.reveal(a, "in-view", .{
        .scroller = "#feed",   // wire "sl"; .scroller_handle for island ref
        .once     = true,
    }))

v1 note: snap stays window-scoped and pin uses position:fixed — neither composes with container scrollers yet.

Snap ease + directional — override the snap glide ease and bias the snap target toward scroll travel:

.scrollTrigger(.{
    .snap             = .{ .step = 0.25 },
    .snap_ease        = .in_out_sine,  // any Ease; default .out_cubic
    .snap_directional = true,          // bias toward travel direction
})

Text, paths, morphs

ctx.h2("Split me").splitText(.{ .by = .chars })   // also .words, .words_and_chars, .lines
    .animate(anim.from(a, ".st-char").opacity(0).y(18).stagger(.{ .each = 0.03 }));

.animate(anim.to(a, ".marker")
    .motionPath(.{ .path = svg_d, .rotate = true })  // viz.edgePathD output plugs in
    .duration(4).repeat(-1))

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

.splitText emits .st-char / .st-word / .st-line spans. Requires display:inline-block in CSS for transforms to apply.

Grapheme clustering.by = .graphemes keeps emoji families, skin-tone modifiers, regional-indicator flags, and combining marks whole (UAX#29, computed SSR in Zig, zero JS):

ctx.p("👨‍👩‍👧‍👦 Hello 🏴󠁧󠁢󠁳󠁣󠁴󠁿")
    .splitText(.{ .by = .graphemes })
    .animate(anim.from(a, ".st-char").opacity(0).y(12).stagger(.{ .each = 0.04 }))

RTL-aware.rtl_aware = true wraps consecutive RTL codepoints in <span dir="rtl"> so the browser reorders glyphs within each run. data-st-i indices stay logical-order dense across the full text:

ctx.p("Hello שלום World")
    .splitText(.{ .by = .words, .rtl_aware = true })
    .animate(anim.from(a, ".st-word").opacity(0).duration(0.5)
        .stagger(.{ .each = 0.04 }))

Set dir="auto" on the host element for predominantly-RTL text.

See it live: animation engine

Draggables — zero wasm

ctx.div().class("card").draggable(anim.draggable(a, .{
    .bounds = .{ .selector = ".pen" },
    .inertia = .on,
    .snap = .{ .grid = .{ .x = 40, .y = 40 } },
    .toggle_class = "dragging",
}))

Add .bounce (elasticity [0, 1]) for elastic reflection when a throw hits a bound. Requires both .inertia (non-.off) and .bounds (non-.none) — omitting either returns a validation error (BounceWithoutInertia / BounceWithoutBounds):

ctx.div().class("card").draggable(anim.draggable(a, .{
    .bounds  = .{ .selector = ".pen" },
    .inertia = .on,
    .bounce  = 0.2,   // ~0.2 gives a GSAP-like feel; 1 = fully elastic
}))

See it live: animation engine

Sortable

Drag-to-reorder lists with FLIP-animated sibling shifts, optional cross-list transfer, and edge autoscroll — zero WASM:

ctx.ul().sortable(anim.sortable(a, .{
    .items        = "li",        // required: CSS selector for items
    .handle       = ".grip",     // grip sub-selector; null = whole item
    .axis         = .y,          // .x / .y (default) / .both
    .animate      = true,        // FLIP sibling shift
    .autoscroll   = true,        // edge scroll; .autoscroll_edge_px = 40
    .toggle_class = "dragging",
}))

Cross-list transfer — give two lists the same .group and items can move between them. .on_enter_group_slot fires on both the source and target containers; use SortableHandle.fromContainer() / toContainer() to disambiguate:

ctx.ul().id("todo").sortable(anim.sortable(a, .{
    .items = "li", .group = "board",
}))
ctx.ul().id("done").sortable(anim.sortable(a, .{
    .items = "li", .group = "board",
    .on_enter_group_slot = enter_slot,
}))

Island-side after settle: verve.SortableHandle.{ .id = id }.lastFrom() / .lastTo() read slot indices before/after the move.

See it live: sortable demo

ScrollSmoother

return content.smoothScroll(.{ .smooth = 1.2 }).build();

Native scrolling stays (scrollbar, keyboard, anchors); visuals ease behind it. Add .parallaxSpeed(0.5) / .parallaxLag(0.4) to layers. position: fixed breaks inside transformed content — use .pin = .self scroll triggers, which counter-translate instead.

Imperative — from an island

Chunks drive the same engine: build a timeline against live elements, then control it (animPlay, pause/reverse/speed via the control API), capture FLIP states (flipCapture → mutate DOM via listDiffflipPlay), register drag handles with callbacks, and read scroll-trigger progress. The anim example's island exercises all of it.

Next: WebGL.