Editorial cover graphic with a blueprint grid motif in gold on cream.

Getting GSAP timelines to survive a Canvas re-render

Scroll-driven motion inside Drupal Canvas, and the teardown step that stops it breaking on edit.

The friction

I wired a scroll-triggered reveal into a Canvas section, nothing fancy, a fade-up on the heading, the usual ScrollTrigger with a start: 'top 80%'. Worked perfectly the first time. Then I nudged the section’s spacing preset in the Style panel, Canvas swapped in the re-rendered markup the way it’s supposed to, and the animation started firing twice. Nudge it again: three times. By the fifth edit the heading was fading in and out like it couldn’t decide whether it wanted to exist.

What was actually happening

Canvas edits a section in place. Change a style option, and it doesn’t reload the page: it re-renders just that section’s markup and swaps it into the DOM, which is exactly the experience you want as an editor and exactly the thing that will wreck a naive GSAP setup. My original code ran ScrollTrigger.create() once, on page load, bound to the section element that existed at that moment. Canvas doesn’t ask GSAP’s permission before replacing that element. It just replaces it, and the old ScrollTrigger instance doesn’t know its trigger element is gone. It’s still in ScrollTrigger.getAll(), still listening, still firing against a DOM node that isn’t attached to the page anymore, right alongside the brand new ScrollTrigger I’d just created for the section’s replacement.

Every edit added one more zombie instance. Five edits, five ghosts, all watching the same scroll position and firing the same tween against elements in various states of existing.

The fix

The animation setup moved into gsap.context(), scoped to the section, with the context reverted the instant Canvas signals it’s about to swap that section’s markup:

canvas-motion.js
function bindSectionMotion(section) {
const ctx = gsap.context(() => {
gsap.from(section.querySelector('.section-heading'), {
y: 24,
opacity: 0,
scrollTrigger: { trigger: section, start: 'top 80%' },
})
}, section)
return ctx
}
let ctx = bindSectionMotion(section)
section.addEventListener('canvas:before-rerender', () => {
ctx.revert()
})
section.addEventListener('canvas:after-rerender', (event) => {
ctx = bindSectionMotion(event.detail.section)
})

ctx.revert() kills every ScrollTrigger and tween the context created and restores the inline styles GSAP had touched, so the outgoing DOM node isn’t left half-animated before Canvas discards it. The new section gets its own context on canvas:after-rerender, bound to the fresh element Canvas actually kept. No zombies, no double-fires, and no slowly accumulating scroll listener count on a page an editor had been iterating on for twenty minutes. That last part I hadn’t fully appreciated until I watched it in the profiler.

What I haven’t solved yet

canvas:before-rerender fires for any change to the section, including ones that don’t touch the elements my animation cares about. Pick a different heading colour and the whole timeline tears down and rebuilds for no reason. It’s cheap enough that it hasn’t mattered on anything I’ve built so far, but it’s clearly the wrong granularity, and I don’t yet have a clean way to ask “did the thing my animation is bound to actually change” without re-implementing a chunk of DOM diffing myself. If Canvas ever exposes a more targeted re-render event, this whole file gets simpler. Until then it’s on the list of a level down.