Theatrum Animation
WordPress plugin adding GSAP scroll, load, and hover animations to any block from the block inspector, plus a JS-free CSS utility layer. TypeScript + Vite.
by Anna Jennings (Theatrum Mundi) · github.com/abananaj/theatrum-animation
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/abananaj/theatrum-animation/archive/refs/heads/main.zipWordPress plugin — adds GSAP animations to any block via the block inspector, triggered on scroll, load, or hover. Also ships a standalone, JS-free
tma-*CSS utility layer and a GSAP-driven stagger option for cascading a parent block's entrance children. Updated: 2026-07-08 — stagger +tma-*CSS utilities added. Seedocs/stagger-and-css-utilities-plan.mdfor the design. Previously updated 2026-07-05, post code review — seedocs/jul5-code-review.md.
Overview
| Plugin file | theatrum-animation.php |
| Build | Vite (two configs) — dist/main.js (frontend) + dist/editor.js (block editor) |
| Animation engine | GSAP 3 + ScrollTrigger |
| Editor integration | WordPress block filter (editor.BlockEdit HOC) |
| CSS class model | Animation applied as a CSS class on the block wrapper (e.g. slide-in-top, fade-in, heartbeat) |
Architecture
src/
├── index.ts ← frontend entry: init + MutationObserver
├── engine.ts ← shared animation state/helpers (ANIMATION_CONFIGS, buildPaused, etc.) — used by index.ts and stagger.ts
├── stagger.ts ← bindStaggerGroups(): GSAP stagger for a parent's entrance children
├── scss/
│ └── utilities.scss ← standalone tma-* CSS utility classes (no GSAP/JS involved)
├── config/
│ ├── animationConfigs.ts ← AnimationConfig interface
│ ├── registry.ts ← REGISTRY (single source of truth for editor + frontend)
│ └── scrollTrigger.ts ← ScrollTrigger config (trigger: top {point}%, once: true — point from data-animation-trigger-point, default 85)
├── block-editor/
│ └── inspector.tsx ← HOC: InspectorControls panel (Animation + Stagger), block filters
├── entrance/ ← 16 animation groups
├── exit/ ← 19 animation groups
├── attention/ ← 12 animation groups
├── text/ ← 8 animation groups
├── background/ ← 3 animation groups
└── basic/ ← 20 animation groups
Two Vite builds:
vite.config.js→dist/main.js(IIFE, bundles GSAP, frontend only)vite.config.editor.js→dist/editor.js(IIFE, externalizes React +@wordpress/*incl.@wordpress/i18n, bundles GSAP for the Preview button; block editor panel)
PHP hooks:
wp_enqueue_scripts→main.js(frontend, loaded withstrategy: 'defer')enqueue_block_editor_assets→editor.js(block editor sidebar)
Frontend animations honor prefers-reduced-motion: reduce — initializeAnimations() no-ops entirely when the user has that OS preference set (WCAG 2.3.3 / 2.2.2).
Animation Categories
~60 animations across 6 categories, all driven by REGISTRY in config/registry.ts.
| Category | Count | Notes |
|---|---|---|
| 🚪 Entrance | 16 groups | slide-in, fade-in, rotate-in, bounce-in, flicker-in, puff-in, roll-in, scale-in, swing-in, swirl-in, tilt-in + variants |
| 🚶 Exit | 19 groups | slide-out, fade-out, rotate-out, bounce-out, flip-out, puff-out, slit-out, swing-out, swirl-out + variants |
| ⚠️ Attention | 12 groups | heartbeat, shake, vibrate, wobble, jello, ping, pulsate, blink, bounce, flicker, scale-up/down |
| ⌨️ Text | 8 groups | tracking-in/out, text-shadow-drop/pop, text-pop, text-flicker, blur-out, focus-in |
| 🖼️ Background | 3 groups | color-change (2x–5x), kenburns (8 directions), bg-pan (6 directions) |
| ✨ Basic | 20 groups | swing, slide, shadow-drop/pop/inset, scale, rotate, flip + variants |
AnimationConfig shape
interface AnimationConfig {
name: string
duration: number // ms
ease: string // GSAP ease string
from?: gsap.TweenVars
to?: gsap.TweenVars
repeat?: number
yoyo?: boolean
timeline?: (el: Element) => gsap.core.Timeline
}
One-shot tweens use from/to + ScrollTrigger. Looping/multi-step animations use timeline and bypass ScrollTrigger (play immediately on load) — this is an intentional design decision, not a bug: entrances need a trigger point, ambient attention/background loops don't.
Every CSS class key must be unique across the whole REGISTRY — flattenConfigs() (frontend) is last-category-wins and buildClassIndex() (inspector) is first-category-wins, so a duplicate key makes the frontend play one config while the inspector shows another. This bit us once: it was a three-way scale-up/scale-down collision across exit, attention, and basic — attention's looping variants are now namespaced attn-scale-up-* / attn-scale-down-*, and exit's duplicate one-shot definitions were deleted outright.
Dynamic blocks: blocks.getSaveContent.extraProps only writes data-animation-* overrides into statically-saved block HTML — server-rendered blocks (most theatrum-blocks query/meta blocks) never pass through it. inc/render-block.php's render_block filter closes this gap: it reads the same override attributes off $block['attrs'], and — gated on WP_Block_Type::is_dynamic() so static blocks are untouched — writes the equivalent data-animation-*/data-stagger-* attributes onto the block's outer wrapper via WP_HTML_Tag_Processor, matching the JS writer's value formats exactly.
Ken Burns caveat: kenburns-* configs transform the block wrapper itself, so applying it to a Cover block scales the whole block (including any overlaid text), not just the background image. A true Ken Burns effect needs the transform on an inner image layer (e.g. .wp-block-cover__image-background) with overflow: hidden on the wrapper — not yet implemented.
Block Editor Panel
src/block-editor/inspector.tsx — fully auto-generated from REGISTRY:
| Control | When shown |
|---|---|
| Category dropdown (trigger-grouped) | Always |
| Animation dropdown | After category is selected |
| Variant dropdown | Only if animation has >1 variant |
| Duration (ms) | After a variant/animation is applied |
| Delay (ms) | After a variant/animation is applied |
| Trigger Point (%, 0–100) | After a variant/animation is applied, and only when the resolved trigger is On Scroll — meaningless for Load/Hover |
Ease — Power (power1–power4, back) |
After a variant/animation is applied, and only for one-shot tweens (hidden for timeline-based animations, which have no single ease to override) |
Ease — Direction (in, out, inOut) |
Same as above |
| Preview Animation button | After a variant/animation is applied |
| Reset Animation button | Any animation is active |
Easing composed as power1.out, written to data-animation-ease on save. Duration/delay written to data-animation-duration / data-animation-delay. Trigger Point written to data-animation-trigger-point (only when it differs from the 85% default). For timeline-based animations, Duration rescales the timeline's playback speed (timeScale()) and Delay restarts it with a delay(); there's no per-step ease to override.
Undo/redo sync is implemented (useEffect([className]) + a suppressSync ref in withAnimationInspector) — the ref is only raised when a handler's className write actually changes the value, so a no-op write (e.g. picking a category with no class yet applied) can't latch it and swallow the next real external change.
Triggers
The Category dropdown groups its options under three trigger headers (disabled rows, version-safe vs <optgroup>); the trigger is implied by which group you pick — there is no separate trigger field.
| Trigger | Categories | Frontend behavior | Saved? |
|---|---|---|---|
| On Scroll | Entrance, Text, Basic | one-shot when the block scrolls into view (ScrollTrigger top {point}%, once — default 85) |
data-animation-trigger-point (only if not 85) |
| On Load | Entrance, Text, Basic | one-shot immediately on page load | data-animation-trigger="load" |
| On Hover | Attention, Background | plays while hovered, pauses on mouseleave; touch → tap-to-toggle | nothing |
Trigger is resolved on the frontend (src/index.ts resolveTrigger) as data-animation-trigger attribute → else the class's category default (flattenTriggers() in registry.ts, keyed off each Category.trigger). Because scroll and hover come from the category default, only the Load override is ever persisted — via the animationTrigger block attribute written by the inspector when you pick an animation from the On Load group. The frontend dispatches per config-shape × trigger: one-shot from/to tweens use GSAP's integrated scrollTrigger for scroll or play immediately for load; timeline/looping configs are built paused and played on scroll-in or on hover.
Trigger Point: the viewport % from the top that fires an On Scroll animation (GSAP's top {point}% shorthand) is per-block, resolved by resolveTriggerPoint() in engine.ts from data-animation-trigger-point (0–100, default 85 — the value every scroll trigger used before this control existed). A stagger group's shared boundary uses the parent block's own override (or 85 if unset); per-child overrides don't affect the group.
Stagger
A parent block with 2+ inner blocks gets a Stagger inspector panel (below the Animation panel) with two controls: Stagger Each (ms between children) and Stagger From (Start/End/Center/Edges/Random) — GSAP's own stagger model (gsap.utils.distribute), no grid/axis options. Setting Stagger Each writes data-stagger-each/data-stagger-from onto the parent's saved HTML.
Preview Stagger / Reset Stagger buttons work like the Animation panel's Preview/Reset, but Preview plays the whole child cascade at once rather than a single block: it reads the block's children (getBlockOrder), pulls each child's own applied animation + Duration/Delay/Ease overrides from its block attributes (the editor canvas has no data-animation-* to read — that's save-only — so this mirrors the same workaround the single-block Preview already uses), skips hover-triggered children, and fires all eligible children together with the same gsap.utils.distribute() offsets the frontend uses.
On the frontend, src/stagger.ts's bindStaggerGroups() runs before the normal per-element sweep in index.ts: for each [data-stagger-each] parent, it collects direct children whose resolved trigger is scroll or load (hover/attention children are excluded and animate independently), computes each one's delay offset via gsap.utils.distribute({ each, from }), builds them all paused (buildPaused() from engine.ts), and plays the whole group together — gated on the parent scrolling into view via onScrollIntoView(), or immediately if every member is Load-triggered.
Scope: static and dynamic parent blocks (Group, Columns, Row, etc., plus dynamic blocks via inc/render-block.php, see Dynamic blocks above) — data-stagger-* is written either way. Direct children only, no recursion into grandchildren.
Shared engine module: applyOverrides, resolveTrigger, buildPaused, ANIMATION_CONFIGS, and the processed WeakSet moved out of index.ts into src/engine.ts so stagger.ts could import them without creating a circular import (index.ts calls bindStaggerGroups(), so stagger.ts importing back from index.ts would cycle). buildPaused() also picked up a fix while it moved: its timeline-based branch now calls tl.delay(delay), which it previously never did — needed for stagger offsets to apply to timeline-based animations (e.g. flicker-in), and incidentally fixes the same silent no-op for any existing scroll/hover timeline animation's Delay override.
Brand Motion (alias layer)
src/config/brand.ts (added 2026-10-05) maps the entrance effects content actually uses onto a small house vocabulary, at runtime only — saved content keeps its old classes until a later content migration. engine.ts's resolveAnimation() runs every element through it before playing.
| Old effect(s) | Plays as | Motion |
|---|---|---|
slide-in*, slide-fade-in, slide-in-fwd/bck/blurred/elliptic, fade-in*, scale-in*, tracking-in* |
ct-enter |
fade in + 32px from the left, slow-4 (600ms) |
| any effect on an image/featured image/video/embed | unchanged | photo effects are hand-picked — they play their own config |
scale-in-hor-* |
ct-rule |
clip-path wipe left→right, slow-3 (500ms) |
scale-in-ver-*, a .wp-block-cover itself, anything inside .ct-page-header |
static | no animation |
- One curve and scale: durations and the ease are read from theme.json's motion tokens (
--wp--custom--motion--duration--slow-4/-slow-3,--wp--custom--motion--ease--power-3turned into a GSAPCustomEase), so tweens and CSS transitions share them. Without the theme: 600/500ms andpower3.out. - Locked timing: brand motions ignore per-block Duration/Ease overrides; Delay, Trigger, Trigger Point and Stagger From still apply. Groups whose children are all brand motions stagger at the house 70ms.
- Arrive composed: a brand element already past its trigger line when bound (
triggeredOnArrival()) stays static — page headers on every template and the first screen never animate, which also removes the visible→hidden→animate flash a deferred script would otherwise cause above the fold. Below-fold elements get their from-state long before they're scrolled to, so no CSS pre-hide is needed. - Everything enters from the left only: an off-left start can't create horizontal scroll on mobile.
- Unaliased effects (attention, text-pop, hover effects…) play their own registry configs exactly as before.
- The editor's Preview still plays the original effect — the alias is frontend-only until the content migration.
CSS Utilities (tma-*)
src/scss/utilities.scss — a standalone, JS-free set of utility classes, separate from the GSAP REGISTRY (no inspector UI; apply via a block's Additional CSS Class(es) field). Prefixed tma- to avoid any collision with the GSAP registry's class keys (renamed from tm- — that prefix collided in substring searches with unrelated tm-* classes shipped by theatrum-blocks, e.g. .tm-table-advanced, .tm-slider).
- Entrance (fires on load/paint via
@keyframes+animation-fill-mode: both, not scroll-gated):.tma-slide-in-up,.tma-slide-in-down,.tma-slide-in-left,.tma-slide-in-right,.tma-fade-in,.tma-scale-in-subtle. - Hover/focus (transition-based,
:focus-visiblealongside:hoverfor keyboard parity):.tma-hover-lift,.tma-hover-grow,.tma-hover-shadow,.tma-hover-brighten,.tma-underline-grow. - Motion tokens:
--tma-duration-{fast,base,slow},--tma-ease-{standard,decelerate,accelerate}. - Respects
prefers-reduced-motion: reduce(own@mediablock, independent of the GSAP frontend's reduced-motion gate).
Delivery: no separate stylesheet is enqueued. This Vite build has no HTML entry point to extract CSS against (a plain .ts entry → IIFE), so Vite bundles utilities.scss into dist/main.js and injects it via `document.head.appendChild(