Animation with GSAP and Motion
gsap and motion on BUI's engine, motion props on every element and in React, springs solved in closed form, and frame-exact tests with advanceClock.
- JavaScript
A box that slides in with an elastic ease, a timeline of three steps, a card that springs when you hover it: GSAP’s and Motion’s APIs, run by BUI. You import gsap or motion, call them as you would anywhere, and the animation runs in BUI’s engine. JavaScript hands over each call’s values once, and hears from the engine only for the callbacks you ask for.
GSAP
import { gsap } from '@blender-ui/native/gsap'
gsap.to('.box', { x: 200, duration: 1, ease: 'elastic.out(1, 0.3)', stagger: 0.1 })gsap.from('#title', { opacity: 0, y: 20 })gsap.fromTo(el, { scale: 0.8 }, { scale: 1, ease: 'back.out(2)' })
const tl = gsap.timeline({ repeat: 1, yoyo: true, defaults: { duration: 0.5 } })tl.to('#a', { width: 100 }) .to('#b', { width: 100 }, '<0.2') // GSAP's position strings .addLabel('mid') .to('#c', { opacity: 0.2 }, 'mid+=0.25')
const t = gsap.to(el, { x: 100, onComplete () { console.log('done') } })t.pause(); t.seek(0.5); t.reverse(); t.progress(0.25)await t // resolves when it ends- Targets: selectors (
'#id',.class,tag,[attr=v]), elements, motion values, or one plain object, whose numbers are tweened and written back each frame. - Properties:
x,y,scale,rotate,opacity, and any CSS property that interpolates. - Options:
duration,delay,ease,repeat,repeatDelay,yoyo,stagger,paused,reversed,overwrite,immediateRender,timeScale, and the callbacksonStart,onUpdate,onComplete,onRepeat,onReverseComplete,onInterrupt. - Overwrite: a new tween on a property takes over from the old one and keeps its velocity.
Motion
import { animate, motionValue, stagger } from '@blender-ui/native/motion'
animate('.card', { opacity: 1, y: 0 }, { duration: 0.4, delay: stagger(0.05) })animate(el, { x: 100 }, { type: 'spring', stiffness: 300, damping: 20 })
const x = motionValue(0)animate(x, 100, { type: 'spring', bounce: 0.25, duration: 0.5 })animate(0, 100, { onUpdate: v => console.log(v) })Any element takes Motion’s props with setMotion:
el.setMotion('initial', { opacity: 0, y: 20 })el.setMotion('animate', { opacity: 1, y: 0 })el.setMotion('whileHover', { scale: 1.05 })el.setMotion('transition', { type: 'spring', stiffness: 400, damping: 30 })el.setMotion('drag', 'x')el.setMotion('layout', true)el.style = { x: motionValue(0) } // a motion value bound to a propertyThe props are initial, animate, exit, whileHover, whileTap, whileFocus, whileInView (with viewport), whileDrag, transition, variants, drag (true, 'x', 'y') with dragConstraints, dragElastic, dragMomentum and dragSnapToOrigin, and layout (true, 'position', 'size'). setMotion(name, null) removes one, and an unknown name throws.
In React
import { motion, useMotionValue, useSpring, useTransform } from '@blender-ui/react/motion'
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }} />- Host elements:
motion.div,motion.spanand the rest take the props above;motion.Button,motion.NumberSliderand the other Blender widgets animate their own values. - Motion values:
style={{ width: mv }}follows a value without rendering the component again. - Exit animations: an element with an
exitthat you remove stays and animates out before it goes;AnimatePresencepasses its children through. - Not there yet:
layoutId.
Springs and eases
type: 'spring' is a real spring: the engine solves the damped spring equation in closed form, for the underdamped, critically damped and overdamped cases. A frame reads the value at its time, so the frame rate doesn’t change the result, and interrupting a spring hands its position and velocity to the next motion. Give stiffness, damping and mass, or duration and bounce. type: 'inertia' decays from a velocity.
Every ease of GSAP, Motion, CSS and Blender works by name: 'none', 'power2.out', 'sine.inOut', 'expo.out', 'back.out(1.7)', 'elastic.out(1, 0.3)', 'bounce.out', 'steps(4)', 'easeInOut', 'anticipate', 'cubic-bezier(0.2, 0.8, 0.4, 1)', a [x1, y1, x2, y2] array, or a name you registered with CustomEase.
Web Animations
el.animate(keyframes, options) runs for real, on the same engine. Status: built, with play, pause, reverse, finish, cancel, playbackRate, currentTime, onfinish and finished.
const a = el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, easing: 'ease-out', fill: 'forwards' })await a.finishedendDelay and a composite other than 'replace' throw. cancel() stops it and rejects finished with an AbortError, but the engine cannot yet revert an animated style, so the first keyframe’s values stay until you set the style yourself.
Svelte transitions
Status: built. Svelte’s transition:, in: and out: run on this engine with no import: Svelte calls el.animate() with the transition’s css, and the engine runs it. A transition that only has tick runs in Svelte’s own loop.
<script> import { fly } from 'svelte/transition' let open = $state(false)</script>{#if open}<div transition:fly={{ y: 20, duration: 300 }}>hello</div>{/if}An outro keeps the element until it ends, and showing it again during the outro keeps one element. Svelte cancels a finished animation, so give an element that transitions explicit styles (see the cancel() note above). app.advanceClock(ms) steps a transition in tests (bindings/svelte/test/svelte.test.js). use:motion and the spring and tweened stores are not built.
Numbers and positions
Every call checks its numbers where you make it: a NaN or infinite duration, delay, repeatDelay, seek or timeScale throws a RangeError, a negative duration or delay is 0, repeat: -1 (or Infinity) is forever and a repeat above 1,000,000 is clamped, and a position that is not GSAP’s grammar ('<=1', {}) throws instead of being taken as a label. The whole contract is under Numbers and positions.
Testing animations
A headless app’s clock stands still. Move it by hand to check a value frame by frame:
const app = new App({ headless: true })// ...open a window, start an animation...await app.advanceClock(500) // half a second later, every window has rendered that frameExamples
examples/animation/ has four small windows, each run with node examples/animation/<name>.js, or saved as frames with BUI_SHOT=out.png BUI_SHOT_MS=100,500,3000:
hero.js: a headline whose letters rise one by one, one GSAP timeline with position strings.cards.js: a card grid with staggered entrance, spring tilt on hover and counting numbers.magnetic.js: buttons that lean toward the pointer and snap back with a spring.playground.js: a marquee, a draggable puck and an equalizer.
React and Svelte: bindings/react/test/motion.test.js and bindings/svelte/test/svelte.test.js are small runnable examples of motion.div, useTransform, AnimatePresence and Svelte transitions.
Limits
- Widgets: a Blender widget has no draw transform or opacity;
x,y,scale,rotateandopacityapply to HTML elements. A widget animates its own values. - QuickJS:
bui buildwithout--bunruns on QuickJS, which has no animation commands yet:gsap,animateandel.animatethrow an error that says so. Node and Bun have them. - Not built:
gsap.quickTo,context,matchMedia,utils,ScrollTrigger,Flip,Draggable,Observer, Motion’sspring,scroll,inView,hover,press, animation sequences, and in ReactuseVelocity,useScroll,useInView,useAnimate,LayoutGroup,MotionConfigandlayoutId. The full table. - Cost: a frame that only animates skips rendering, the cascade and layout and repaints only what changed. It is not allocation-free: each call hands its values over once, and a callback such as
onUpdatecosts an event per frame it runs.
Next steps
Last updated