There are three ways to put motion on a hosted page. Pick one per element before building anything:
| Way | Where it is stored | On the hosted site | Use it for |
|---|---|---|---|
| Build Studio's Animate panel (animejs v4) | The active screen's config.animations, one entry per element, keyed by the element's data-node-id. Nothing is written into the HTML. | Plays once the page has loaded and React has finished starting up. Page's own active screen only. | Fade or slide in on scroll, hover and click effects, scroll-linked movement, number counters, letter-by-letter text, staggered cards, multi-step timelines |
| animate.css classes (Props Editor › Animate tab) | class="animate__animated animate__fadeInUp" on the element | Plays once from the first paint, with no JavaScript. The site loads animate.css 4.1.1 on every page. | A simple entrance above the fold, where waiting for the page to load would show a flash |
| Your own CSS and page JavaScript | <style> in the page <head>, code in Page settings → Style | Plays as written | Reveal-on-scroll classes, :hover transitions, anything in the header or footer |
The Tailwind animation classes animate-spin, animate-ping, animate-pulse and animate-bounce also work on a hosted page (tested: animate-pulse ran).
For the page record itself, see Build a website page. For the header and footer, see Shared header and footer. For making the page work on phones, see Make a page work on phones and tablets.
- The element toolbar's AI › Add Animation asks the AI for animejs code and shows it, but Apply only closes the dialog. Nothing is saved (
AIContextMenu.tsx720–745). - In the Props Editor's Animate tab (animate.css), the speed, repeat and delay choices only change the preview tiles. Picking an effect writes just
animate__animated animate__<name>(property-animate.tsx31–38). Typeanimate__slow,animate__repeat-2oranimate__delay-2sinto the Class tab yourself.
Ask first
- Which page, and which elements? Name each one ("the hero heading", "the three feature cards", "the 12,500 customers number").
- What should each do, and when? Fade or slide in, grow, spin, count up, follow the scroll. When: on load, when scrolled into view, on hover, on click, or tied to the scroll position.
- Once or every time? Scroll-into-view plays once by default.
- Is it in the header or footer? The Animate panel can't animate those (step 1). Use CSS and JavaScript in root's Page settings instead (step 4).
- Is the page live? On a site that serves drafts, a save is live at once.
Steps
1. Build Studio: read the page and mark each element
Read the whole page: repo_find on page with { "data.site": "<site>", "data.name": "<page>" }, then repo_find_one on page with its id. A find result is partial.
Find the screen that renders: the one whose id is data.activeScreenId, else screens[0]. Animations live in that screen's config.animations and act on elements in that screen's html.
Every element to animate needs a data-node-id, unique on the page:
- A page Build Studio has saved already has one on every element (
n-plus 8 characters). Use the existing id. Never rename it: the animation is found by that id alone. - HTML you write yourself: add your own, e.g.
<h1 data-node-id="hero-title">. Build Studio keeps an id that is already there and only adds one where it's missing. Ids it adds are random, so you can't predict them.
<section class="stats">
<h2 data-node-id="stats-title">Trusted by teams</h2>
<ul class="cards" data-node-id="stats-cards">
<li>Fast</li><li>Simple</li><li>Secure</li>
</ul>
<p><span data-node-id="stats-count">0</span> customers</p>
</section>The site only plays the page's own animations. It reads the active screen's config.animations of the page being viewed (page-client.tsx 69–99). Animations saved on the root page's header or footer screens, or on a parent page's screens, never play. Tested: a parent page's animation stayed still while the child page's own animation played on the same request.
Hands on: the page id, the active screen's id, and the data-node-id of each element.
Check: each id appears once in that screen's html.
2. Build Studio (Animate panel): each element's animation
On the canvas: select the element, then click the Play icon on its toolbar (or Show Animation Timeline in the right toolbar). The Animate panel opens beside the Props Editor. The toolbar icon turns violet once the element has an animation.
| Panel section | What it sets |
|---|---|
| Keyframes | The poses, left to right. Pose the element on the canvas, then + to capture it. Click a dot to make it active; style changes on the canvas then go into that pose. Arrows move a pose; the copy icon duplicates it; a pose can have its own duration from the one before. Fewer than two poses removes the animation. |
| Preview | Play, Pause, Restart, Reverse, and Reset to start pose, on the canvas only |
| Presets | Ready-made animations: Entrance, Exit, Attention, Continuous, Spring, Group (children one after another), Text (letters or words), Counter, SVG, Attr. A preset replaces the whole animation. |
| Trigger | Type, plus Threshold and Replay (In view), Reverse on leave (Hover), Start, End and Speed (Scroll) |
| Motion | Duration (50–10,000 ms), Delay (0–10,000 ms), Ease |
| Target | This element, Direct children or All descendants, with Stagger (ms), From (first, last, center) and reverse |
| Loop | Count (once, 2, 3, 5, ∞), Mode (Restart or Yoyo), Gap between loops, Direction (reversed) |
| Counter / Text split / Motion path | Shown only when a preset added them |
| Callbacks | JavaScript run on begin, complete, loop, update |
- Captured poses hold pixel sizes. + copies about 70 properties from the canvas,
width,height,padding,marginandfontSizeamong them, measured at the canvas width (animate-side-panel.tsx1414–1427). The site writes them onto the element as inline styles. Tested: a pose holdingwidth: 1180pxleft that width on the element at 390 px, and the phone page zoomed out to 1,204 px wide. Clicking a pose dot also writes all of them onto the element on the canvas, and a save keeps them in the HTML. Prefer the presets, and keep poses toopacity,translateX/Y,scale,rotateand colours. Before saving, click the last pose so the element is saved in its resting state. - Changing any other field erases Counter, Text split, Motion path, Callbacks, Gap, Direction and playback rate. The panel rebuilds the record from a fixed list of fields on every change, and those aren't on it (
animate-side-panel.tsx1454–1475). For example, pick Count 0 → 100 and then change Duration, and the counter is gone. Set those last, or write the record directly (below).
Through the API (or to check what the panel saved), each entry of config.animations is one record:
| Field | Values | On the hosted site |
|---|---|---|
enabled | true / false | false skips it (tested) |
presetId | custom or a preset name | Ignored. The site has no preset list, so { "presetId": "fade-up", "trigger": "load" } with no states or params plays nothing (tested). Always write states. |
trigger | load, inView, hover, click, scroll | Table below |
states | [{ "style": { … } }, …], at least 2 | The keyframes, spread evenly over duration. If any pose after the first has its own duration (ms from the pose before) or ease, those are used instead |
duration, delay | ms | Panel defaults: 700, 0 |
ease | "out(2)", "outExpo", "inOutSine", "outBack(1.5)", "linear", or { "kind": "back" | "elastic" | "cubicBezier" | "steps" | "spring", … } | animejs v4 names |
target | self, children, descendants | children with stagger cascades (tested: each <li> animated) |
stagger, staggerFrom, staggerDirection | ms; first / last / center / index; normal / reverse | |
loop, alternate, reversed, loopDelay, playbackRate | number or true; booleans; ms; multiplier | Passed to animejs |
inView | { "threshold": 0.2, "once": true } | Defaults shown |
hover | { "reverseOnLeave": true } | |
scroll | { "start": 0, "end": 1, "speed": 1 } | 0 = the element's top at the bottom of the screen, 1 = its bottom at the top |
counter | { "from": 0, "to": 12500, "decimals": 0, "format": "{n}+", "separator": "," } | Writes the number into the element's text (tested: 12,500+). Needs no states |
textSplit | { "by": "chars" | "words" | "lines", "stagger": 30 } plus states | Splits the text into spans and runs states on each (tested). A hidden copy of the text stays for screen readers |
motionPath | { "pathSelector": "#path", "rotate": true } | The element follows that SVG <path>, which must be on the page |
steps | [{ "id", "kind": "tween", "states", "duration", "delay", "ease", "target", "stagger", "position" }, { "id", "kind": "call", "position" }] | A timeline: tweens one after another, or at position (ms or "+=200"). Overrides the single-tween fields (tested). A call step does nothing |
callbacks | { "onBegin", "onComplete", "onUpdate", "onLoop" }: JavaScript text, the animation available as self | Run in the visitor's browser (tested: onComplete set an attribute) |
params | raw animejs parameters | Used only when states is missing: SVG attributes, <input> values |
What each trigger does on the hosted site (all tested at 390 and 1440 px, base-app animate-runtime.ts 419–499):
trigger | Plays | Note |
|---|---|---|
load | Once, as soon as the page has finished starting up | Not on first paint. The element shows at its normal look until then and then jumps to the first pose. Tested on the local server: about 2.4 s after the first paint. |
inView | When threshold of the element (default 20%) is on screen; again on each entry if once is false | Until then the element shows normally. An element more than five screens tall never reaches 20%; lower the threshold for long sections, which grow taller on a phone. |
hover | On pointer enter; plays backwards on leave (reverseOnLeave) | Phones have no hover. Don't hide anything behind it. |
click | On every click | |
scroll | Progress follows the scroll between start and end | Single tweens only: a scroll animation made of steps does nothing (tested) |
An example: the heading rises in on load, the cards follow one by one when scrolled to, the number counts up.
{
"stats-title": { "enabled": true, "presetId": "custom", "trigger": "load", "duration": 600, "ease": "outExpo",
"states": [ { "style": { "opacity": 0, "translateY": "24px" } }, { "style": { "opacity": 1, "translateY": "0px" } } ] },
"stats-cards": { "enabled": true, "presetId": "custom", "trigger": "inView", "target": "children", "stagger": 120, "duration": 500,
"states": [ { "style": { "opacity": 0, "scale": 0.9 } }, { "style": { "opacity": 1, "scale": 1 } } ] },
"stats-count": { "enabled": true, "presetId": "custom", "trigger": "inView", "duration": 2000, "ease": "outExpo",
"counter": { "from": 0, "to": 12500, "separator": ",", "format": "{n}+" } }
}Hands on: the config.animations object for the screen.
Check: every key is a data-node-id in the screen's html; every entry has trigger and either states (2 or more), steps, counter, motionPath or params; no pose holds a width, height, padding, margin or fontSize in px.
3. Build Studio: save it
On the canvas: save the page. Build Studio writes the panel's map into the active screen's config.animations on every save (selective-virtual-dom.ts 1424–1436). Deleting an element drops its animation. Duplicate and paste give the copy new ids, so the copy has no animation; add it again.
Through the API: change the screen and send the whole screens array back. repo_update replaces each key of data you send, so { "screens": [...] } replaces all screens.
- From the
repo_find_oneresult, takedata.screens. - In the active screen, set
config.animationsto the full map. Keep the screen's otherconfigkeys (variants,activeVariants). repo_updateonpagewith{ "screens": <the whole array> }. Leavehtmlalone; only the config changed.
page_write with html replaces the active screen's html and keeps its config. The new HTML must still carry the same data-node-ids, or the animations lose their elements.
A page with no screens (only data.html) plays data.config.animations instead (tested). Build Studio doesn't load that map, though. The first save from the canvas turns the page into a screen with an empty config.animations, and from then on those animations no longer play. Put animations on a screen.
Stop: on a site that serves drafts, the save is live. Show the person which elements move and how, and wait for yes.
Hands on: nothing new.
Check: repo_find_one shows the map under data.screens[<active>].config.animations, and the screen's html is unchanged.
4. Build Studio: CSS-only motion (above the fold, header and footer)
Use this where the Animate panel can't reach or would flash.
- animate.css: add
animate__animated animate__fadeInUp(any animate.css 4 name) in the Class tab, withanimate__delay-1s…5s,animate__slow/slower/fast/faster,animate__repeat-1…3/animate__infinite. It plays once on load from the first paint (tested). It doesn't wait for scroll. - Reveal on scroll with your own CSS, the way PhaZZer's site does it: hide the element with a class, and add a class from page JavaScript when it comes into view.
/* page <head> <style>, or root's Page settings → Style for the whole site */
.reveal { opacity: 0; transform: translateY(28px); transition: opacity .6s, transform .6s; }
.reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } }// Page settings → Style → JavaScript
(function boot() {
if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
var els = document.querySelectorAll('.reveal:not(.in)');
if (!els.length) return void setTimeout(boot, 30);
if (!('IntersectionObserver' in window)) { els.forEach(function (el) { el.classList.add('in'); }); return; }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
});
els.forEach(function (el) { io.observe(el); });
})();Tested: the element sat at opacity 0 until scrolled to, then got in and showed. Write the JavaScript as in Build a website page step 4: page_write appends to what is there. For the header and footer, put both in root's Page settings → Style.
Hands on: nothing new.
Check: the class is on the element in the screen HTML; for a reveal, the script is in style.javascript at the top level of the record.
5. Check it on the site
Open the page in a real browser (headless Chrome is fine) at 390, 768 and 1440 px wide. Wait until the page has finished starting up before judging a load animation.
- Every animated element ends in its resting state:
getComputedStyle(el).opacityis1, and its inlinestyleholds only what the animation set. - Scroll to each
inViewelement; it plays once. - At 390 px,
window.innerWidthis still 390. If it is larger, a pose or element is wider than the phone (see Make a page work on phones and tablets). - With reduced motion on (Chrome DevTools › Rendering › prefers-reduced-motion: reduce, or CDP
Emulation.setEmulatedMediawithprefers-reduced-motion: reduce), nothing moves and everything is visible. Tested: the site skips every Animate-panel animation. animate.css cuts its own to 1 ms (its stylesheet's rule); your own CSS needs its ownprefers-reduced-motionrule, as in step 4.
To turn all Animate-panel animations off on one page, switch off Animate content in the page's Page settings (data.animateContent: false). Tested: nothing played.
When it's done
Tell the person: the page, each animated element and what it does and when, which ones are animate.css or CSS reveals instead, that visitors who ask for reduced motion see no motion, and anything they asked for in the header or footer and how it was done. Open the page in Build Studio and on the site.
What it can't do yet
- Animations on
root, a parent page or a wrapper page never play. Only the viewed page's active screen is read (base-apppage-client.tsx76–83). ?screen=previews play the active screen's animations, not the shown screen's.- No play-before-load.
loadandinViewelements show normally until the page has finished starting up, then jump to the first pose. Hiding them in CSS first leaves them invisible if the script never runs. presetIdalone does nothing on the site, and the panel's presetapplyfunctions don't exist there.scrollwithstepsdoes nothing;callsteps run nothing.- The panel can't author
steps,playbackRateormotionPath.offset. Write them through the API. - AI › Add Animation doesn't apply its result.
- The Animate tab's speed, repeat and delay aren't written to the element.
- Editing in the panel erases Counter, Text split, Motion path, Callbacks, Gap, Direction and playback rate (step 2).
- Captured poses carry pixel sizes onto the element, on the canvas and on the site (step 2).
Don'ts
- Don't change or reuse a
data-node-idthat has an animation. - Don't write
config.animationson root or a parent page; it never plays there. - Don't send a partial
screensarray or a partialconfig; it replaces what is there. - Don't rely on
presetId; writestates. - Don't put
width,height,padding,marginor font sizes in poses. - Don't hide content that matters behind hover, or hide it in CSS waiting for an animation.
- Don't use the AI › Add Animation dialog and assume it saved anything.