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 listDiff → flipPlay), register drag handles with callbacks, and read scroll-trigger progress. The anim example's island exercises all of it.
Next: WebGL.