WP Manifestindependent plugin directory
manifest / editor / theatrum-animation

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

★ 0stars
0forks

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.zip

WordPress 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. See docs/stagger-and-css-utilities-plan.md for the design. Previously updated 2026-07-05, post code review — see docs/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 with strategy: '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-3 turned into a GSAP CustomEase), so tweens and CSS transitions share them. Without the theme: 600/500ms and power3.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-visible alongside :hover for 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 @media block, 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(