RPIC

animate

rpic extension. animate declares how objects enter the drawing — as metadata. The static SVG is untouched except for its stable per-shape ids (s0, s1, …); a thin web player drives a GSAP timeline against those ids.

.PS
margin = 0.12
B1: box "load" fit
arrow
B2: box "run" fit
arrow
C: circle "done" rad 0.3
animate B1 with "pop" for 0.4
animate 1st arrow with "draw"
animate B2 with "pop" after 1st arrow
animate 2nd arrow with "draw"
animate C with "fade" for 0.8
.PE
load run done

This page is the host document — the timeline above is playing live (hit ▶ replay). The draw effect traces paths; pop overshoots, which is why the source reserves headroom with margin.

Beyond entrances, the palette tells a story — a label that types itself in, steps that pop in sequence, and a wiggle to point at the one that matters:

.PS
margin = 0.15
A: box "parse" fit
arrow
B: box "eval" fit
arrow
C: box "draw" fit
T: "rpic animates" at B.n + (0, 0.55)
animate T with "type" for 1.0
animate A with "pop" after T
animate B with "pop" after A
animate C with "pop" after B
animate B with "wiggle" wiggles 6 after C
.PE
parse eval draw rpic animates

Quick start — plain HTML, no build step

The zero-effort path is the CLI writing the whole page for you:

rpic --html -o fig.html fig.pic

That’s a single self-contained file — SVG, manifest and the GSAP player inlined, and GSAP itself (plus only the plugins your effects need) pulled from a pinned, integrity-checked CDN tag. Open it and the timeline plays; animate scroll sources get scroll-scrubbing wired, draggable objects respond. A drawing with no animations becomes a plain static page.

Prefer to own the page? Everything here runs from two CDN files: GSAP and the rpic package (which ships the player). No bundler required.

Compile in the browser. The wasm build compiles pic source on the page and plays the manifest in one go:

<div id="stage"></div>
<script type="module">
  import { ready, compile, animate } from
    'https://cdn.jsdelivr.net/npm/@strategicprojects/rpic@0.11.0/index.js';
  import { gsap } from 'https://cdn.jsdelivr.net/npm/gsap@3.13.0/+esm';

  await ready();                      // the .wasm is fetched from the CDN
  const { svg, animations } = compile(`box "A"; arrow; box "B"
animate 1st box with "pop"
animate last arrow with "draw"
animate last box with "fade"`);
  const stage = document.querySelector('#stage');
  stage.innerHTML = svg;
  animate(stage, animations, gsap);
</script>

Or pre-render with the CLI. rpic --json fig.pic gives you { svg, animations, … }; paste both into a static page and import only the player — animate() never touches the wasm, so nothing is compiled at runtime. This is the classic GSAP header pattern:

<head>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"
          integrity="sha384-HOvlOYPIs/zjoIkWUGXkVmXsjr8GuZLV+Q+rcPwmJOVZVpvTSXQChiN4t9Euv9Vc"
          crossorigin="anonymous"></script>
  <!-- only if the source uses move / morph / scramble / wiggle: -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/MotionPathPlugin.min.js"
          integrity="sha384-ZdcNM4JVCcTz+LG7hy3tkzchm/ljdYJcwthjW8G4vqAXApMm4ve+3ByX+RfrAlFt"
          crossorigin="anonymous"></script>
</head>
<body>
  <div id="stage"><!-- paste the `svg` string here --></div>
  <script type="module">
    import { animate } from
      'https://cdn.jsdelivr.net/npm/@strategicprojects/rpic@0.11.0/player.js';
    gsap.registerPlugin(MotionPathPlugin); // only with the plugin above
    const animations = [/* paste the `animations` array here */];
    animate(document.querySelector('#stage'), animations, gsap);
  </script>
</body>

player.js is the player as its own zero-import module, so the page fetches nothing wasm-related at all — importing index.js also works but statically pulls the wasm glue JS (harmless, just ~10 KB you don’t need with a pre-rendered SVG).

The same pattern loads dist/MorphSVGPlugin.min.js, dist/ScrambleTextPlugin.min.js and dist/CustomEase.min.js + dist/CustomWiggle.min.js when those effects are in play — see the plugin note below. Pin the versions you tested (jsdelivr also accepts ranges like @0.10). If you have a bundler, skip the CDN and npm install @strategicprojects/rpic gsap — see Consuming the manifest.

Syntax

animate <place> with "<effect>" [along <path>] [into <shape>] [to <colour>] [from <dir>]
        [out] [stagger <d>] [for <dur>] [at <t> | after <place>] [delay <d>] [repeat <n>] [yoyo] [ease "<name>"]

Only the target and effect are required; the trailing clauses are optional and may appear in any order.

Clause Example Meaning
animate place with “effect” animate B1 with "pop" animate that object (required)
along place along L path to follow — required by move, ignored elsewhere
into place into B shape to morph into — required by morph, ignored elsewhere
to colour to "crimson" target colour for highlight; any rpic colour form, ignored elsewhere
from dir from left entry direction (up/down/left/right) — required by slide, ignored elsewhere
by word|char|“chars” by word, by "01" type reveal granularity (default char), or a scramble charset; ignored on other effects
wiggles n wiggles 6 oscillation count for wiggle (default 6), ignored elsewhere
out out play the effect as an exit (reverse) instead of an entrance
stagger expr stagger 0.12 on a block target, fan the effect across its children, ds apart
for expr for 0.4 duration in seconds (default 0.6)
at expr at 1.5 start at an absolute time
after place after 1st arrow start when that object’s animation ends
delay expr delay 0.3 offset added on top of the computed start
repeat expr repeat 2 replay count; -1 loops forever, 0/absent plays once
yoyo yoyo reverse direction on each repeat (needs repeat)
ease “name” ease "elastic.out" override the effect’s default easing with any GSAP ease

The place is any native pic reference — a label (B1), an ordinal (1st arrow, 2nd last line, last box), or previous. The target must be a drawn shape; animating a bare point (a name with no geometry) is an error.

Effects

Effect Motion Easing
fade opacity 0 → 1 power1.out
pop scale 0 → 1 back.out(1.7) (overshoots)
draw strokes trace themselves; arrowheads and text pop/fade in as the shaft reaches them. Narrow to a sub-segment with from <p>/to <p> none for the trace
move travel along another object’s path (needs along) none
highlight recolour + thicken the outline with a scale pulse (to <colour>), or a colour-free pulse power1.inOut
slide translate in from a compass direction (needs from <dir>) power2.out
morph tween the outline into another shape’s geometry (needs into <shape>) power1.inOut
type reveal a label one character (or by word) at a time — a typewriter none
scramble glyphs cycle through random characters and resolve to the text (custom set with by "…") none
wiggle a quick oscillating shake that returns to rest — “look here” emphasis (wiggles <n>) CustomWiggle

Any other effect string is accepted by the parser but flagged with an unknown_animation_effect warning in the --json bundle — it renders nothing.

move, morph and scramble need GSAP plugins registered

rpic only emits metadata — the browser player does the animating. Three effects lean on optional GSAP plugins that you must register before playing the timeline:

Use GSAP ≥ 3.13. Since 3.13 (May 2025, after Webflow’s acquisition) the entire toolset — MorphSVGPlugin and ScrambleTextPlugin included — is free, even commercially; on older versions those were paid Club GreenSock plugins that silently loaded a watermarked trial build. Register them once, up front:

import { gsap } from 'gsap';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';
import { CustomEase } from 'gsap/CustomEase';
import { CustomWiggle } from 'gsap/CustomWiggle';
gsap.registerPlugin(MotionPathPlugin, MorphSVGPlugin, ScrambleTextPlugin, CustomEase, CustomWiggle);

The other effects (fade, pop, draw, slide, highlight, type) use only the GSAP core and need no plugins. animate scroll additionally wants ScrollTrigger — see Scroll-scrubbing below.

Timing model

Every animation resolves to an absolute start and duration in seconds. There are four ways to place one on the timeline:

  1. Sequential (default). With no at/after, an animation begins when the previously declared one ends — animations cascade in source order.
  2. after place — begin when a specific named object’s animation ends. This lets you branch instead of running one straight chain.
  3. at t — pin to an absolute second (run things in parallel, or line up with an external soundtrack). at and after write the same slot, so if you give both the last one wins.
  4. delay d — shift whatever start the clauses above produced by d seconds. It stacks on sequential, at, or after.

The example below exercises all four in one timeline:

.PS
margin = 0.12
B1: box "load" fit
arrow
B2: box "run" fit
arrow
C: circle "done" rad 0.3
animate B1 with "pop" for 0.4
animate 1st arrow with "draw"
animate B2 with "pop" after 1st arrow
animate 2nd arrow with "draw" at 1.2
animate C with "fade" for 0.8 delay 0.3
.PE
load run done

Its --json manifest is a flat, absolute-time array — no player needed to read it:

[
  { "id": "s0", "effect": "pop",  "start": 0,   "duration": 0.4 },
  { "id": "s1", "effect": "draw", "start": 0.4, "duration": 0.6 },
  { "id": "s2", "effect": "pop",  "start": 1,   "duration": 0.6 },
  { "id": "s3", "effect": "draw", "start": 1.2, "duration": 0.6 },
  { "id": "s4", "effect": "fade", "start": 2.1, "duration": 0.8 }
]

Looping and easing

repeat, yoyo, and ease map straight onto the GSAP tween that plays each object — they let an object pulse, breathe, or bounce instead of entering once.

An infinite repeat never blocks the sequence: sequential and after timing track only the first pass, so a looping highlight can run under everything else.

.PS
margin = 0.2
P: circle "ping" rad 0.35 fill 0.9
box "settle" with .w at P.e + (0.6,0) fit
animate P with "pop" for 0.7 repeat -1 yoyo ease "sine.inOut"
animate last box with "fade" for 0.6 ease "elastic.out(1, 0.4)"
.PE
ping settle

Only the keys you set reach the manifest, so this timeline is:

[
  { "id": "s0", "effect": "pop",  "start": 0, "duration": 0.7,
    "repeat": -1, "yoyo": true, "ease": "sine.inOut" },
  { "id": "s1", "effect": "fade", "start": 0.7, "duration": 0.6,
    "ease": "elastic.out(1, 0.4)" }
]

Easing reference

rpic passes the ease string straight through — it never parses or validates it, so any name GSAP understands works, and that page is the authoritative catalogue (including plugin eases like CustomEase once you register them). The names follow one pattern:

<family>.<variant>          e.g. power2.out, sine.inOut, bounce.in
<family>.<variant>(<args>)  e.g. back.out(1.7), elastic.out(1, 0.3), steps(6)

The variant is the curve’s shape — always spell it out (a bare power2 is ambiguous):

Variant Motion
.in starts slow, accelerates into the end
.out starts fast, decelerates to a stop — the everyday choice (GSAP’s default is power1.out)
.inOut eases at both ends

The families (each with all three variants unless noted):

Family Feel
none / power0 linear — no easing (no variants)
power1power4 polynomial ramps, gentle (power1) to sharp (power4)
sine soft, sinusoidal
expo very sharp — near-flat, then snaps
circ circular arc
back overshoots and settles; back.out(1.7) sets the overshoot
elastic springy wobble; elastic.out(amp, period) tunes it
bounce bounces like a dropped ball
steps(n) snaps through n discrete stops (no variants)

Because it’s an opaque passthrough, a typo ("powr2.out") sails through rpic and only surfaces in the browser, where GSAP falls back to its default ease — so copy names from the GSAP page, or use its live ease visualizer to preview a curve before pasting.

Explore every ease

Pick a family and in/out/inOut to see its curve and the motion side by side — the marker climbs the axis with that ease. Every panel here is drawn by rpic itself: each curve is plotted from the ease’s own formula (the in and inOut shapes are derived from out), and the marker rides move with ease "<family>.<variant>" — the exact string you’d pass in your own source. The plugin eases (rough/slow/expoScale, and CustomEase/CustomBounce/ CustomWiggle) have no closed-form formula for rpic to plot, so they’re not in this explorer — but their motion works the same once you registerPlugin and pass the name to ease. The panel below the curve shows the exact pic source for the selected combination — copy it and render it yourself.

The curves, drawn in rpic

Words only go so far — here is each family’s .out curve, plotting input time (x, 0 → 1) against eased progress (y, 0 → 1). The dashed diagonal is the linear (no-ease) reference; back/elastic/bounce overshoot past the top of the cell on purpose. This whole figure is itself drawn by rpic — the source samples every ease formula in a for loop, so it’s a working demo of the engine’s own maths (sin/exp/sqrt/^, if/else for the piecewise bounce). Copy it and plot the .in or .inOut variants to compare.

power1.out power2.out power4.out sine.out expo.out circ.out back.out elastic.out bounce.out
The nine ease families' .out curves, each sampled and plotted by rpic — examples/ease-gallery.pic

The static grid above becomes the graph view of GSAP’s visualiser once you animate it: the curve draws itself (its segments are a block, revealed by draw + stagger), while a value marker rides move up the axis on the right with the same ease — so the marker’s height traces the curve’s value as it’s drawn (hit ▶ replay). The only piece pic can’t do is GSAP’s live numeric value: readout — there’s no animated counter.

.PS
margin = 0.25
u = 2.4
n = 28
box wid u ht u thick 0.7 outlined 0xcbd5e1 with .sw at (0,0)
line from (0,0) to (u,u) dashed thick 0.6 colored 0xcbd5e1
Curve: [
for i=0 to n-1 do {
  t=i/n; s=(i+1)/n
  a=1-(1-t)^3; b=1-(1-s)^3
  line from (u*t,u*a) to (u*s,u*b) thick 2.4 colored 0x2f855a
}
] with .sw at (0,0)
VT: line from (u+0.45,0) to (u+0.45,u) thick 0.9 colored 0xcbd5e1
VD: dot at VT.start rad 0.09 colored 0x0ae448
"power2.out" at (u/2, -0.28)
animate Curve with "draw" for 0.05 stagger 0.06
animate VD with "move" along VT ease "power2.out" for 1.7 repeat -1 yoyo at 0
.PE
power2.out

And the same eases as motion — a live “ease visualiser” like GSAP’s, read as movement instead of a graph. Six dots cover an identical track in the same time, differing only in ease, so you compare them side by side (hit ▶ replay): none marches at a constant rate, power2.out/expo.out shoot ahead then coast in, back/elastic overshoot and spring back, bounce.out overshoots the end and settles — exactly the curves above, in motion. Each dot rides move along its track with repeat -1 yoyo, so they loop forever.

.PS
margin = 0.2
dy = 0.5
L0: line from (0,0)     to (3,0)     thick 0.8 colored 0xcbd5e1
L1: line from (0,-dy)   to (3,-dy)   thick 0.8 colored 0xcbd5e1
L2: line from (0,-2*dy) to (3,-2*dy) thick 0.8 colored 0xcbd5e1
L3: line from (0,-3*dy) to (3,-3*dy) thick 0.8 colored 0xcbd5e1
L4: line from (0,-4*dy) to (3,-4*dy) thick 0.8 colored 0xcbd5e1
L5: line from (0,-5*dy) to (3,-5*dy) thick 0.8 colored 0xcbd5e1
D0: dot at L0.start rad 0.08 colored 0x2f855a
D1: dot at L1.start rad 0.08 colored 0x3182ce
D2: dot at L2.start rad 0.08 colored 0x805ad5
D3: dot at L3.start rad 0.08 colored 0xdd6b20
D4: dot at L4.start rad 0.08 colored 0xe53e3e
D5: dot at L5.start rad 0.08 colored 0x319795
"none"               ljust above at L0.start + (0.12,0.04)
"power2.out"         ljust above at L1.start + (0.12,0.04)
"expo.out"           ljust above at L2.start + (0.12,0.04)
"back.out(1.7)"      ljust above at L3.start + (0.12,0.04)
"elastic.out(1,0.3)" ljust above at L4.start + (0.12,0.04)
"bounce.out"         ljust above at L5.start + (0.12,0.04)
animate D0 with "move" along L0 for 2 ease "none"               repeat -1 yoyo at 0
animate D1 with "move" along L1 for 2 ease "power2.out"         repeat -1 yoyo at 0
animate D2 with "move" along L2 for 2 ease "expo.out"           repeat -1 yoyo at 0
animate D3 with "move" along L3 for 2 ease "back.out(1.7)"      repeat -1 yoyo at 0
animate D4 with "move" along L4 for 2 ease "elastic.out(1,0.3)" repeat -1 yoyo at 0
animate D5 with "move" along L5 for 2 ease "bounce.out"         repeat -1 yoyo at 0
.PE
none power2.out expo.out back.out(1.7) elastic.out(1,0.3) bounce.out

Motion along a path

The move effect sends its target travelling along another object’s geometry — a token down a wire, a packet along an arrow, current around a loop. Name the path with along <place>; it can be any drawn line, arrow, or spline. The traveller is usually a small dot placed at the path’s start.

.PS
margin = 0.2
A: box "in" fit
arrow right 1.6
B: box "out" fit
D: dot at 1st arrow.start rad 0.07 colored "red"
animate 1st arrow with "draw" for 0.8
animate D with "move" along 1st arrow for 1.2 after 1st arrow
.PE
in out

The manifest entry gains a path key naming the followed object’s id ("path":"s1"), so the player can drive it with GSAP’s MotionPathPlugin:

[
  { "id": "s1", "effect": "draw", "start": 0, "duration": 0.8 },
  { "id": "s3", "effect": "move", "start": 0.8, "duration": 1.2, "path": "s1" }
]

The dot travels once and rests at the path’s end. move without an along path is an error; an along on any other effect is ignored with an along_without_move warning. Add repeat -1 for a continuous flow, or repeat -1 yoyo for a there-and-back shuttle.

Partial draw — reveal a stroke segment

By default draw traces the whole stroke, 0 → 100%. Two optional clauses — from <p> and to <p> — narrow it to any sub-segment, given as a fraction of the stroke (60%, or the bare fraction 0.6):

animate P with "draw"                 # unchanged: 0% → 100%
animate P with "draw" to 60%          # trace only the first 60%
animate P with "draw" from 40% to 60% # reveal just the 40%–60% window

Reach for it to sweep a highlight along one leg of a wire, reveal a mid-section, or animate a moving window down a path. out retracts the window instead of growing it. The example below reveals only the middle third of the arc — drawn a second time in grey behind it as a track, a common idiom so the whole path stays visible while the window highlights a slice (as on the home page):

.PS
margin = 0.2
arc from (0,1.1) to (3.4,1.1) rad 2 thick 2.4 colored "0xcbd5e1"
A: arc from (0,1.1) to (3.4,1.1) rad 2 thick 2.4 colored "0x2f855a"
animate A with "draw" from 33% to 66% for 1.4
.PE

Each end rides the manifest only when set ("drawFrom":0.4,"drawTo":0.6, as fractions), so a plain draw stays byte-identical. Out-of-range values clamp to [0,1]. The player realises the window with a pure stroke-dasharray sweep — no extra GSAP plugin. from/to here are the reveal fraction only for the draw effect; on slide/highlight those same words keep their direction/colour meaning.

Fractions, not pixel ranges

rpic’s window is a fraction of the stroke (40%), not GSAP DrawSVGPlugin’s absolute pixel range ("20 350"). Fractions are resolution- independent and need no plugin; if you need exact pixel offsets, drive DrawSVGPlugin yourself against the emitted s<N> element.

Morphing shapes

morph tweens an object’s outline into another shape’s geometry — a box becoming a circle, one symbol flowing into the next. Name the target with into <shape>; both are ordinary drawn objects, so place the target where the morph should land (a shared centre reads as a shape-shift in place). The player uses GSAP’s MorphSVGPlugin.

.PS
margin = 0.25
B: circle rad 0.45 at (1.2,0)
A: box wid 0.8 ht 0.8 "shape" fit at (1.2,0)
animate A with "morph" into B for 1.2 repeat -1 yoyo ease "power1.inOut"
.PE
shape

The manifest entry gains a morph key naming the target’s id ("morph":"s0"). The target must be a visible shape (an invis reference draws no geometry to read). morph without into is an error; into on any other effect is ignored with an into_without_morph warning.

Emphasis

highlight draws the eye to an already-drawn object — the “now look here” beat of a step-by-step explanation. With to <colour> it recolours and thickens the object’s outline (any rpic colour form — a name, rgb(), 0xRRGGBB) and gives it a small scale pulse, so the emphasis reads at a glance; without a colour it’s a colour-free scale pulse.

Like the other effects it’s a one-directional tween, so it composes with the timing clauses: add repeat 1 yoyo for a single flash-and-return, or repeat -1 yoyo for a continuous pulse.

.PS
margin = 0.15
A: box "start" fit
arrow
B: box "critical" fit
arrow
C: box "end" fit
animate B with "highlight" to "crimson" for 0.6 repeat -1 yoyo
.PE
start critical end

The target colour rides the manifest entry as a color key ("color":"crimson" above; rgb()/0xRRGGBB resolve to #rrggbb). to on any non-highlight effect is ignored with a to_without_highlight warning.

Attention wiggle

wiggle gives an object a quick oscillating shake that returns to rest — the “look here” nudge, without moving the object from its place. Set the number of oscillations with wiggles <n> (default 6). It’s built on GSAP’s CustomWiggle ease, so register that (and CustomEase) — see the callout above.

.PS
margin = 0.15
A: box "start" fit
arrow
B: box "look!" fit
arrow
C: box "end" fit
animate B with "wiggle" wiggles 8 for 0.8 repeat -1 delay 1
.PE
start look! end

The count rides the manifest as "wiggles":8; absent, the player uses 6. wiggles on any non-wiggle effect is ignored with a wiggles_without_wiggle warning.

Bouncy easing

Any GSAP ease name passes straight through ease "<name>", so a springy landing is already one word away — ease "bounce.out", ease "elastic.out", ease "back.out(1.7)". For a tunable bounce (strength, squash-and-stretch), register CustomBounce, CustomBounce.create("myBounce", { strength: 0.6, squash: 3 }), and reference it by name: ease "myBounce".

Typewriter text

type reveals a label one piece at a time — the way a caption “speaks” a step. By default each character appears in turn; add by word to reveal whole words instead. The whole reveal spans the effect’s for duration.

.PS
margin = 0.15
"Describe the picture." at (0,0)
animate last with "type" for 1.6
"rpic draws it." at (0,-0.4)
animate last with "type" by word for 0.9 after 2nd last
.PE
Describe the picture. rpic draws it.

Unlike GSAP’s SplitText (which does not support SVG <text>), the split is native: the SVG backend wraps each unit of a type target in a <tspan class="rpic-ch">, and the player staggers their opacity — no plugin, nothing to install. The tspans carry no positioning, so the pre-animation render is visually identical to a plain label, and a drawing with no type animation emits byte-identical text. by on any non-type effect is ignored with a by_without_type warning; out reverses it into a staggered erase.

Scramble reveal

scramble cycles a label’s glyphs through random characters and resolves them into the real text — a “decoding” reveal. Unlike type it needs no split: GSAP’s ScrambleTextPlugin drives the <text> element’s content directly, so the effect has zero cost on the SVG side. Pass a custom character set with by "…" (default is upper-case letters).

.PS
margin = 0.15
box "ACCESS GRANTED" fit
animate last with "scramble" for 1.4
.PE
ACCESS GRANTED

The charset rides the manifest as a chars key ("chars":"01" for a binary “Matrix” look); absent, the player uses upperCase. Needs ScrambleTextPlugin registered (see the callout above). out scrambles the text away to nothing; by "…" on a non-scramble effect warns by_without_scramble.

Staggering a group

Point animate at a [ … ] block and add stagger <d> to fan the effect across the block’s children — each successive child starts d seconds after the previous, so a row or grid assembles itself one piece at a time. It expands to one ordinary manifest entry per child, so the timeline stays a flat array.

.PS
margin = 0.15
Row: [ box "1"; move 0.2; box "2"; move 0.2; box "3"; move 0.2; box "4" ]
animate Row with "pop" for 0.4 stagger 0.12
.PE
1 2 3 4

Only the block’s visible children get a slot — invisible helpers (explicit moves, invis spines) are skipped, so the four boxes above stagger evenly even though moves separate them. Children fan in source order; the sequence resumes after the last child, so a following animate picks up cleanly. stagger on a non-block target is ignored with a stagger_without_block warning (the single object still animates).

Direction and exit

slide enters an object by translating it in from a compass direction — from up, down, left, or right — offset by its own size so it clears its slot. The out modifier is orthogonal: it turns any effect into an exit, reversing the tween (fade away, pop out, retract a draw, slide off). Together they let a diagram tell a two-beat story — build up, then tear down.

.PS
margin = 0.3
A: box "L" fit
B: box "U" fit at A + (1.2,0)
C: box "R" fit at B + (1.2,0)
animate A with "slide" from left for 0.5
animate B with "slide" from up for 0.5 delay 0.15
animate C with "slide" from right for 0.5 delay 0.3
.PE
L U R

out composes with the timing clauses, so an object can enter and later leave — here a box pops in, then fades out a beat afterwards:

.PS
margin = 0.2
S: box "step" fit
animate S with "pop" for 0.4
animate S with "fade" out for 0.5 after S delay 0.6
.PE
step

slide needs a from direction (omitting it is an error); from on any other effect is ignored with a from_without_slide warning. out rides the manifest as "out":true, and slide’s direction as "from":"left" — both only when set.

A control loop drawing itself, wires first:

.PS
texlabels = 1; margin = 0.12
arrow "$u$" above
S: circle rad 10/72.27
line right 0.35
G: box "$G(s)$" wid 1 ht 0.55
arrow "$y$" above
line -> down G.ht from last arrow then left last arrow.c.x - S.x then to S.s
animate 1st arrow with "draw" for 0.4
animate S with "pop"
animate 2nd last line with "draw" for 0.3
animate G with "pop" for 0.5
animate 2nd arrow with "draw" for 0.4
animate last line with "draw" for 0.9
.PE

Consuming the manifest

rpic_core::compile_json (and the JS/Python bindings) return { svg, animations, diagnostics, warnings }. Each animation is { id, effect, start, duration } plus the optional repeat / yoyo / ease / path / color / out / from / morph keys — present only when the source set them, so plain manifests stay compact. The JS package ships the player, which folds those keys straight into GSAP. move needs MotionPathPlugin and morph needs MorphSVGPlugin registered on your GSAP instance (GSAP ≥ 3.13, where both are free — see the note under Effects):

import { ready, compile, animate } from '@strategicprojects/rpic';
import { gsap } from 'gsap';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
gsap.registerPlugin(MotionPathPlugin, MorphSVGPlugin); // for `move` / `morph`
await ready();
const { svg, animations } = compile(src);
stage.innerHTML = svg;
animate(stage, animations, gsap);

Scroll-scrubbing

Autoplay is the default. Add a bare animate scroll directive anywhere to hint that the timeline should instead be scrubbed by scroll position — the diagram builds as the reader scrolls through it. This is a whole-timeline decision, so it isn’t attached to any one object; it surfaces as a top-level scroll: true in the compile bundle (present only when the directive is used).

Scrubbing is the host’s call, so the flag is a neutral hint: the player still returns a GSAP timeline, and the consumer decides how to drive it. Wire it with ScrollTrigger:

import { ready, compile, animate } from '@strategicprojects/rpic';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
await ready();
const { svg, animations, scroll } = compile(src);
stage.innerHTML = svg;
const tl = animate(stage, animations, gsap);
if (scroll) {
  tl.pause();
  ScrollTrigger.create({ animation: tl, trigger: stage, start: 'top 80%', end: 'bottom 20%', scrub: true });
}

Making objects draggable

draggable <place> is a different axis from the timeline: it makes an object grabbable in the browser (GSAP Draggable). Because dragging is interaction, not an authored, play-on-load effect, it is its own directive rather than an animate … with effect.

draggable N                 # N can be dragged
draggable N inertia         # …and thrown, with momentum
draggable N bounds B        # constrain to another object's box
draggable N x               # lock to one axis (x or y)
.PS
margin = 0.2
F: box wid 4 ht 2.2 dashed
A: circle "A" rad 0.3 at F.c + (-1,0)
B: circle "B" rad 0.3 at F.c + (1,0)
line from A to B
draggable A inertia bounds F
draggable B inertia bounds F
.PE
A B

Drag either node — each throws with momentum (inertia) and stays inside the frame (bounds F). The connecting line does not follow (rpic emits static geometry); live graph editing is the host application’s job, for which the interactions metadata is the starting point.

The compiled bundle gains a top-level interactions array ([{id, kind:"drag", inertia?, bounds?, axis?}, …]) alongside animations — absent when nothing is draggable, so plain bundles are byte-identical. The static SVG and geometry are untouched: dragging is a runtime affordance, so PNG/PDF and non-interactive consumers see exactly the same picture.

draggable needs GSAP Draggable registered

Register Draggable (and InertiaPlugin for inertia, both free since GSAP 3.13), then wire the objects with the interactive helper:

import { ready, compile, animate, interactive } from '@strategicprojects/rpic';
import { Draggable } from 'gsap/Draggable';
import { InertiaPlugin } from 'gsap/InertiaPlugin';
gsap.registerPlugin(Draggable, InertiaPlugin);
await ready();
const { svg, animations, interactions } = compile(src);
stage.innerHTML = svg;
animate(stage, animations, gsap);
interactive(stage, interactions, Draggable);

Rules