Skip to content

Animation

Scroll-, load- and click-triggered animation, authored per component from the Style tab and played on the published page by GSAP. Ships as the optional canvas_builder_animate submodule — enable it and an Animation group appears alongside Background, Spacing and Typography.

Everything here is per-viewport and rides the same draft → publish → revision lifecycle as every other style — nothing is a separate content model.

The Animation group: target tabs across the top, Scope indented beneath the active tab, the preset grid including Draw in, then trigger, scroll position and the collapsed Fine-tune drawer showing its recap

The panel

Target tabs

The row of tabs at the top of the group chooses what animates:

  • Whole element — the component's own wrapper, or each of its children.
  • A named region — one tab per data-cb-anim-target marker the component's own template offers. See Named targets.

Each tab holds a completely independent animation: its own trigger, timing, easing and scroll bounds. They run concurrently. A CTA can draw its icon in on scroll while its text rises on click.

Scope

Under the Whole element tab only:

  • This element — animate the component's wrapper as one object.
  • Each child — animate the things inside it, in sequence.

"Each child" descends past a component's single structural root, so it finds the real siblings rather than the one wrapper the SDC happens to emit. Named tabs have no Scope control — their identity already is the target.

Presets

A preset is a named bundle of values, not a label. Picking "Fade up" sets direction, distance, opacity, duration and easing together. Only the preset's id is stored, so retuning a preset in settings updates every component using it.

Shipped: Fade up, Slide in, Scale, Stagger, Draw in, None and Custom. Presets are administrable — that set is a starting point, not a fixed vocabulary. See Settings.

None is the off state. There is no separate enable switch: choosing None is how you turn an animation off, and a component set to None carries no animation config at all — no attribute on its wrapper, and no GSAP downloaded for that page. Everything below the preset grid dims while None is selected; the grid itself stays live, so you can always choose your way back out.

A preset the component cannot support is shown disabled with the reason rather than hidden — see When a preset is unavailable.

Trigger

On scroll, On load, or On click.

On scroll reveals a viewport diagram with two draggable markers: where the animation starts, and — with Scrub on — where it finishes. Scrub ties progress directly to scroll position, like a video scrubber, instead of playing once when the trigger fires.

Fine-tune

Timing, easing, opacity, repeat and once-per-session live behind a Fine-tune disclosure, closed by default, because the preset has usually already decided them. Closed, it carries a one-line recap of what it holds — 0.6s · Power2.out · plays once — so you can read the state without opening it.

It opens by itself when it would otherwise hide a value you set that the recap does not name.

When a preset is unavailable

Some presets need something from the component itself. Draw in animates the length of a stroke, so it needs artwork drawn with strokes — a filled icon has no line to draw.

Those two cases are treated differently on purpose:

  • A requirement you can satisfy from the panel — Stagger needing more than one element — just hides the tile. Pick another target and it returns.
  • A requirement about the component's markup, which the panel cannot change, leaves the tile visible but disabled, with the reason under the grid. Silently removing it would send you hunting for something that was never coming back.

The Icon tab with the Draw in tile dimmed and the reason printed under the grid: needs artwork drawn with strokes, this one is filled, so there is no line to draw

Draw in on a filled icon: the tile stays, dimmed, and says why.

Most icon sets are filled, not stroked

Bootstrap Icons, and any icon built from filled paths, cannot be drawn. Draw in needs a stroked family — Feather, Lucide, Heroicons outline, or hand-authored SVG carrying stroke.

Controls appear only when they can act

A control that cannot do anything is not rendered, rather than greyed out. Scroll position and Scrub exist only for a scroll trigger. Stagger exists only when the target resolves to more than one element. Repeat delay exists only when something repeats.

Nothing is lost by hiding: a stored value stays stored and reappears intact when it becomes applicable again.

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-anim-target="title">{{ title }}</h2>
<div class="card-body" data-cb-anim-target="body">{{ body }}</div>

The Animation panel scans the live preview for those markers and offers a tab per name. 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-anim-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 (anim_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 panel keeps listing the old name, 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 a preset 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's one block of content needs no markers — Whole element already covers it. A container whose children are separate components needs none either; each child carries its own.

Per-viewport behaviour

Every animation value is stored per viewport and resolved mobile-first: a viewport with no entry inherits the nearest narrower one. Switch the device picker and the panel re-points to that viewport's values.

At playback the runtime hands each breakpoint window to gsap.matchMedia(), which reverts a window's tweens automatically when the browser crosses out of it. There are no @media rules and no generated breakpoint class grammar.

Reduced motion

Suppression under prefers-reduced-motion: reduce is a WCAG 2.2.2 obligation the runtime always honours. It is shown in the panel as a locked indicator, not a switch, and there is no configuration key to turn it off. A visitor who asks their system for less motion sees the content in its natural state.

Content is never left invisible. Nothing is hidden by CSS — the from-state is applied by GSAP only — so a blocked GSAP, a reduced-motion visitor, or a tween that throws all end with the element visible.

Settings

Configuration → Content authoring → Canvas Builder → Styles → Animation

  • Presets — the vocabulary offered in the panel. Each carries a label and the bundle of values it applies, plus two optional keys:
    • requires — a detector the target must satisfy for the preset to be offered. svg needs artwork with strokes; multiple needs the target to resolve to more than one element.
    • library — a Drupal library the preset needs in order to play, such as gsap/drawsvg.
  • Easings and Repeat options — the choices the panel lists.
  • Ranges — the slider bounds (max distance, max delay + duration, stagger and repeat-delay ceilings and steps).
  • Defaults — the value every unset key falls back to, site-wide.

A component's effective default is the site default overlaid with the preset it chose, so what the panel shows is what will actually play.

For developers

Storage

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

No anim_* 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-anim JSON blob instead.

What the base module gains

Exactly two things, both in src/Render/CanvasBuilderTrustedCallbacks.php: the anim_* strip, 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 anim_* values inert in storage. PortabilityContractTest pins this.

Runtime

GSAP loads only for pages that actually carry animation config, and ScrollTrigger only when some enabled viewport's resolved trigger could be scroll. 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.