Skip to content

Motion engine architecture

The animation engine's contract, storage layer, and public API.


Overview

Motion is an animation connector, not a wrapper around one library: the panel, dock, stored values and PHP API are engine-neutral, and the thing that actually plays an animation is a swappable engine behind a small versioned contract. The shipped default is the native engine — built entirely on the Web Animations API and CSS, part of this module, with no third-party animation dependency. A site can register a different engine instead, through the same hook a third-party module would use — see Registering an engine.

The contract

Engines implement assets/frontend/motion/engine/contract.js. The contract — its call shapes and the CONTRACT_VERSION export — is public API, versioned with semver: additive capabilities bump the minor, changed call shapes bump the major. File paths are not API. The registry (register(id, provider) / forDocument(doc)) resolves the configured engine per document; an adapter may check CONTRACT_VERSION (currently 0.3.0) at registration.

The calls an adapter implements, all engine-neutral (no engine object ever leaks out):

Call Does
supportsScroll() / supportsStrokeDraw() Capability probes the connector reads before offering scroll-bound or SVG-draw controls.
set(targets, props) Apply property values immediately, no animation.
clear(targets, props) Remove only the named engine-applied inline properties, restoring the natural state — never the whole style attribute.
animate(targets, vars, scroll?) Play one animation against a CBVars bag, optionally scroll-bound via a CBScroll shape. Returns a CBHandle.
batchEnter(elements, opts) Per-element enter trigger that batches elements entering together (long staggered lists). Returns a CBHandle.
sequence(opts?) A timeline: {paused: true} for a seekable detached timeline, {scroll} to bind it to scroll (adds toggleActions), or nothing to play immediately. Returns to/set/play/pause/seek/progress/time/duration/timeScale/repeat/eventCallback/kill/revert.
splitText(el, {type, mask, smartWrap}) Split into line/word/char fragments; returns an instance exposing the unit arrays and revert(), or null when unavailable.
matchViewport() Responsive bucket lifecycle: {add(query, cb), revert()}. cb fires once immediately if query already matches and again on every crossing; it must return a cleanup run when the query stops matching or revert() tears the document down.

Every CBHandle (animate/batchEnter/sequence) exposes kill(): cancels every WAAPI Animation, disconnects every Observer, unregisters scroll listeners, and destroys any pin spacer — safe to call more than once. A handle from animate() also exposes play() (replays from the start, lifecycle re-wired so onComplete fires again, onInterrupt first if a run was in flight) and reverse(); a scroll-bound handle exposes both as plain player dispatches without disturbing its scroll binding.

A CBVars bag is to-values plus timing. Property keys — x, y, xPercent, yPercent, z, scale, scaleX, scaleY, rotation, rotationX, rotationY, autoAlpha, clipPath, width, height, transformOrigin, transformPerspective, backgroundColor, count, countFrom, draw — compose onto the element's current state. Transform order is fixed: perspective() translate(px) translate(%) translateZ() rotate() rotateX() rotateY() scale(). count drives a registered --cb-count custom property (falling back to getComputedTiming().progress where CSS.registerProperty is missing) and rewrites the first digit-bearing text node, keeping prefix, suffix, thousands separators and the authored decimal places; count: 'self' means the number already authored in the element, resolved per element. clear('count') restores the original text.

Timing keys — duration, delay, ease, easeReverse, stagger, repeat, repeatDelay, yoyo (WAAPI direction: 'alternate'), keepAlive (a finished bare tween is not baked-and-cancelled, so its handle can replay), onStart, onComplete, onInterrupt — are never treated as properties.

A CBScroll shape binds a call to scroll position: trigger, start/end, an optional pin (held sticky-in-a-spacer while the window is active) with pinSpacing, scrub (true ties progress 1:1 to scroll; a number lags by that many seconds), once, invalidateOnRefresh, and toggleActions ('enter leave enterBack leaveBack', each one of play/reverse/restart/pause/resume/reset/complete/none — ignored when scrub is set).

sequence().to()/set() takes a position argument: absolute seconds, '+=N'/'-=N' relative to the total duration at insertion, or '<'/'>' for the start/end of the most recent step, optionally with a signed offset (e.g. '<0.2'); results clamp at 0.

The engine's only input is data the spec resolver produces from the stored motion_* values — targets, from/to properties, duration, delay, ease (name or curve), stagger, repeat, trigger, scroll window, split, reduced-motion. Property names are the spec's own (opacity, x), never a library's. Six triggers reach the engine this way: scroll-into-view, scroll-scrub, page load, click, hover and reveal-every-time — see Choosing a trigger.

Known limitations

Not offered by the contract, by design or not yet:

  • Nested timelines (a sequence() inside another).
  • A function-per-target / random value grammar in CBVars.
  • Stagger grid and amount (only each and from).
  • width: auto (and other auto sizing keywords besides height).
  • Two-token scroll start/end grammar (e.g. 'top 80%'); the contract takes a percent of viewport height only.
  • Pointer-follow easing (a value that eases toward a moving pointer position).
  • Shared-element transitions, motion-path animation, and shape morphing.
  • Properties locked by an !important author rule. Animations lose to !important in the cascade, so tweening backgroundColor has no visible effect on a Bootstrap .bg-* utility; it works on any background set without !important.

height: auto tweens correctly: the native engine uses interpolate-size: allow-keywords (Chrome/Edge 129+) where the browser supports it, enabling it once per document, and falls back to measuring the element's own natural height and tweening to that pixel value everywhere else.

Next

Identified during this pass, deliberately not built:

  • Draw-in start/end segment control — today draw is a single 0-100 endpoint (buildKeyframePair/setPropsImmediate both parseFloat one value); a two-value grammar ('20% 80%') plus a from/to UI would let a stroke reveal start and end mid-path instead of always from the top.
  • Per-target function values in CBVars beyond the couple hardcoded today — motion.js's horizontal-track { x: () => -distance() } is the only one, not a grammar an authored preset can reach for its own props.

Registering an engine

function hook_canvas_builder_motion_engines(): array {
  return [
    'my_engine' => [
      'label' => 'My engine',
      'library' => 'my_module/engine',
      'capabilities' => ['timeline', 'scrollDriven', 'seek', ...],
      'catalog' => [...],
    ],
  ];
}
  • Capabilities shape the editor: timeline, scrollDriven, hold, split, draw, stagger, seek, compose, smoothScroll, viewportBuckets. Controls an engine cannot honor are hidden, not greyed — a class-based entrance engine gets a clean reduced surface. Without seek the dock preview offers replay instead of scrubbing; without compose the UI allows one animation per element.
  • Catalogs belong to engines. Each catalog entry declares an id, label, category (entrance, fade, slide, scale, attention…) and params; the effect picker shows the active engine's catalog. Adapters may also declare extra animatable properties with types — the panel renders those controls from the declaration, which is how beyond-DOM engines (3D scenes, vector runtimes) expose what they animate. Targets are opaque to the connector: a DOM engine resolves the stored selector to elements, other engines resolve it by their own convention.
  • One engine per sitecanvas_builder_motion.settings.engine. The engine id is never stored in page data: stored values are engine-neutral, and after an engine switch an effect missing from the new catalog resolves to that catalog's default for its category; a status report lists what was remapped. Stored timelines on an engine without timeline degrade to individual animations, also reported.

The connector keeps two guarantees no engine can override: reduced motion is checked by the connector before any engine is invoked, and pages fail soft — initial hidden states are applied only after the engine loads, so an engine failure leaves a static, fully readable page.

Conformance

The adapter conformance suite lives in this module's Vitest tree: one fixture DOM, the same resolved specs through every engine, asserting start/end styles, stagger offsets, scroll bindings, split fragment counts, and that dispose() leaves the element untouched (outerHTML diff). Point the suite at an adapter file and run npx vitest — a passing run is what "implements the contract" means.

Implementation notes

Two guarantees no engine can break: reduced motion is enforced by the connector before any engine runs, and every page fails soft — content starts visible and only moves to its animated start state once the engine has actually loaded, so a blocked or broken engine never leaves anything invisible.

Storage

Every value rides the existing classes bucket of the component's style bundle under a motion_ key prefix (motion_enabled, motion_preset, motion_duration, …) — not a new field, not a new State store. Read them back at render with getComponentClassViewportMap($uuid, 'motion_duration'), never with getComponentPluginClasses(), which flattens the per-viewport detail away.

No motion_* key ever reaches the wrapper's class attribute. The base module's prerender strips the prefix alongside the overlay keys and emits the config as a data-cb-motion JSON blob instead.

A section's timeline lives in the same style bundle under timeline, holding its trigger and an ordered list of actions. action is the code-level name for what the interface calls an animation.

What the base module gains

Exactly two things, both in src/Render/CanvasBuilderTrustedCallbacks.php: the motion_* strip (which also recognizes the pre-rename anim_* prefix — see Upgrading from an early build), and a conditional runtime library attach. No manager change, no new core service, no key in canvas_builder.settings. Uninstalling the submodule leaves the base module fully functional, with orphaned motion_*/anim_* values inert in storage.

Where the Motion tab comes from

The tab is registered by hook_canvas_builder_prop_groups_alter() at weight 15, which places it between Style (10) and Advanced (20). The style definition itself keeps form_group: 'style' — that is an AJAX plumbing requirement, not where the editor sees it. The panel is hosted in a container carrying data-canvas-prop-group="motion".

The public API

MotionActionApiInterface (canvas_builder_motion.action_api) is the supported way for another module to read or write a section's timeline — it validates targets and actions and throws InvalidMotionDataException rather than letting a bad write reach storage. canvas_builder_ai uses it to author timelines, and reads the animation vocabulary from StyleVocabularyBuilder rather than carrying its own copy of the effect list. Prefer both over touching the style bundle directly.

Saved effects are a Preset content entity, not config, so they behave like content for access and translation.

Discovered preset packs

MotionPresetDiscovery (canvas_builder_motion.preset_discovery) scans every enabled module and theme for a {provider}.canvas_builder_motion_presets.yml file via core's YamlDiscovery, validates each row against the same vocabulary the shipped catalog obeys, and caches the merged result under canvas_builder_motion:preset_packs, tagged config:core.extension so installing, uninstalling or enabling an extension invalidates it automatically. _canvas_builder_motion_panel_settings() folds its rows in after the shipped/config catalog (a discovered id colliding with an existing one is dropped and logged, that catalog wins) and before saved presets, then invokes hook_canvas_builder_motion_presets_alter(array &$presets) — see canvas_builder.api.php for the module's other hooks — so a site can adjust the fully merged list, discovered packs included, before it reaches drupalSettings. A disabled_presets config key, edited from the Effects from themes and modules admin toggle, marks a discovered preset enabled: false — the same "hidden from new picks, existing components unaffected" treatment any shipped preset's own enabled key gets — without touching the pack file itself. See Sharing effects: an effects pack below for the pack-file format.

Named targets

A component author marks an animatable region in their own Twig. There is no schema to declare, no annotation to maintain, and no PHP involved:

<h2 data-cb-motion-target="title">{{ title }}</h2>
<div class="card-body" data-cb-motion-target="body">{{ body }}</div>

The panel scans the live preview for those markers and offers one per name in the target chip. Names are sentence-cased for display only — cta-button reads "Cta button" — while the marker and the stored value stay verbatim.

Rules worth knowing:

  • A marker belongs to the nearest enclosing component, so a nested component's own data-cb-motion-target="icon" is never captured by its parent.
  • The same name may appear several times in one component. Those elements animate together and honour the stagger control.
  • Storage is by slot index (motion_duration__0), so re-pointing a configured group at a different marker keeps its values.
  • Renaming a marker in Twig does not delete its stored animation. The old name keeps being listed, greyed, until you re-point or clear it.
  • max_named_targets (default 6) caps how many one component may configure at once.

Choosing names. Names are yours — the panel shows whatever it finds. A small, reused vocabulary travels better than a bespoke one per component, because an editor learns it once and an effect like Draw in behaves predictably wherever it appears: eyebrow · title · subtitle · body · icon · media · caption · count · cta. Mark only the regions worth animating individually — a component that is one block of content needs no markers (Whole element already covers it), and a container whose children are separate components needs none either, since each child carries its own.

Its own build

The submodule owns its Vite root at modules/canvas_builder_motion/ui/, with one entry point — src/motion-dock/index.js — built to ui/dist/ and attached by the submodule's own hook_library_info_alter() as a type="module" script. The base module ships no motion UI. The build is split in two: motion-dock.js is a small shim (boot.js) that renders the Motion tab's first-run screen, installs the Drupal.canvasBuilderMotion API as load-then-delegate stubs, and imports the hashed motion-dock.<hash>.js chunk — the dock proper — only when the Motion tab is active, the selected component already carries motion, or the API is called. The dock stylesheet is emitted beside the chunk as motion-dock.<hash>.css and linked by the shim before the chunk runs. Commit all three; the hashes are their cache-buster, and the shim's URL carries the library version. Run npm run build there after editing ui/src/**; a stale dist/ is served silently, with no error of any kind.

Upgrading from an early build

Early builds shipped this submodule as canvas_builder_animate, built on a different animation engine. A site that had it enabled migrates automatically the first time canvas_builder runs its database updates: settings and every stored animation value carry over with their keys renamed (anim_*motion_*), and the permission an editor already had to edit pages is extended to the Motion tab. No manual step is required. drush canvas-builder:migrate-motion-keys re-runs the same key rename by hand, for a site that restores a database backup taken before the migration ran.

Runtime

The engine's own library loads only for pages that actually carry animation config — there is no separate scroll-specific plugin to conditionally attach, since the native engine handles scroll triggers within the same file. The runtime stays inert on the live editing canvas — the editor paints its own preview there — and plays on Page Preview and the published front end.

Engine licenses

The engine table below is the license ledger: every dependency or documented engine is pinned to an exact version, and bumping a pin means re-reading its license and updating the row. Two questions are recorded separately, because they are different clauses: may a site serve the library to visitors, and may a visual builder drive it.

Engine / library Version License Site delivery Builder use Verified
Native engine GPL-2.0+ (this module) yes yes
Lenis (smooth scroll) pinned in libraries.yml MIT yes yes 2026-09-01

Rules:

  • Dependencies referenced by this module are MIT/BSD/ISC/Apache-2.0 only, loaded from a CDN with a pinned version, never committed to the repo.
  • The native engine is written from platform specifications and public math only — no other animation library's source is ported or consulted, and its effect catalog is defined the same way.
  • Selecting a non-default engine means the site accepts that engine's license terms; this module warrants only the native engine. The settings form states the same.
  • Engine names appear here nominatively, to identify them — nothing more.

Legacy browser support

The native engine calls the Web Animations API directly and ships no polyfill — Drupal 11 and Canvas already require a current browser, so this module targets the same floor. A site that must still support browsers without native WAAPI (older Safari/Firefox point releases, pre-Chromium Edge, IE) can opt in to web-animations-api-shiv (BSD-3-Clause-Clear) themselves: it converts el.animate() calls into CSS animations for browsers with ES5 and CSS animation support, loaded ahead of the native engine's library. This module does not bundle or reference it — adding it is the site's own choice, the same way a non-default engine is.

Adding effects outside the editor

For a declared-family effect (a custom from/to tween, e.g. a bespoke clipPath wipe or a rotation variant) the admin Effects table can't express — it only edits label, enabled, requires and params, not family, from or to. Export config, add a preset row, re-import:

presets:
  - id: my-custom-wipe
    label: 'My custom wipe'
    family: declared
    from:
      clipPath: 'inset(0 100% 0 0)'
    to:
      clipPath: 'inset(0 0% 0 0)'
    params:
      motion_duration: '0.8'
      motion_easing: 'power3.inOut'

See docs/reference/motion-vocabulary.md in the submodule for the full tween_props key list and config/schema/canvas_builder_motion.schema.yml for the complete preset row shape.

Sharing effects: an effects pack

A theme or module can ship its own effects without touching config at all. Drop a {provider}.canvas_builder_motion_presets.yml file at its root — the filename's first segment must be the theme or module's own machine name — with a top-level presets: list in the same row shape as a config preset row above (id, label, family, from/to, params, …). Each row can also carry a glyph (inline SVG) or an art key to supply its own effect-picker tile picture instead of the plain fallback; a row may use one or the other, never both.

Discovered presets appear in the drawer under a From {Provider} chip, right alongside the shipped and site-configured ones, and a site admin can turn any one off — without touching the pack file — from Configuration → Content authoring → Canvas Builder → Styles → Animation, "Effects from themes and modules". See Discovered preset packs above for the discovery mechanism.

A full worked example, five presets covering declared tweens, a count counter and an ambient yoyo loop, lives at modules/canvas_builder_motion/docs/examples/mytheme.canvas_builder_motion_presets.yml — copy it into a theme's root and rename it to match.

Config keys without an admin form

Three canvas_builder_motion.settings keys are drush config:set only:

Key Purpose Default
max_named_targets Named-target slots rendered on every component form — a hard cap (0–24), not a hint. 6
easings Replaces the panel's easing list wholesale ([{value, label}]). Absent by default so newly shipped easings reach existing sites; a value the engine has no curve for plays linear. (absent — the code list)
repeat_options Same, for the Repeat choices. (absent — the code list)
drush config:set canvas_builder_motion.settings max_named_targets 8

Recovering from a stale config import

A cim run from a sync directory exported before upgrading to 1.0.0-alpha4 can uninstall this module and revert its settings without failing. See the "Config-managed sites" bullet in CHANGELOG.md and run drush canvas-builder:repair-alpha4 to put the site back where updb left it.

Verifying effects × triggers

From modules/canvas_builder_motion/ui, npm run matrix-page -- create seeds a disposable page with one section per effect × trigger (114 today) and prints the editor URL of the first section plus the published URL. Walk both, then npm run matrix-page -- delete — it is never a real page. A comma list narrows it: npm run matrix-page -- create scrub (a trigger), parallax (an effect) or parallax-x-click (one cell).

The automated half: npx playwright test --project motion-matrix runs about a dozen cells against the published page in a few minutes; MATRIX_SUBSET=all runs the whole matrix. --project motion-editor checks that a scrollbar-driven section's dock preview lands on its settled end state, ignores canvas scroll, and only moves on a manual playhead drag. A cell listed under knownIssues in tests/src/Playwright/motion-pressure/matrix.json is expected to fail its contract and goes red the day the fix lands.

Rules for this codebase

  • No engine-specific API outside engine/; the runtime and the dock speak the contract only.
  • Nothing about which engine is active narrows what an editor can configure.
  • New engine capabilities arrive as contract minors with conformance coverage, never as one-off calls from the runtime.