docs
/
AI skills

Animate elements on a page (entrance, scroll, hover, timeline)

Build Studio's Animate panel and how its animations are stored per element in the screen's config, every trigger, keyframe, easing, stagger, counter, text-split and timeline option, how the site plays them after the page loads, reduced motion, what never plays on the hosted site, and the CSS-only alternatives.

There are three ways to put motion on a hosted page. Pick one per element before building anything:

WayWhere it is storedOn the hosted siteUse 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 elementPlays 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 → StylePlays as writtenReveal-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.

Two Build Studio menus look like they animate, but they don't.
  • 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.tsx 720–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.tsx 31–38). Type animate__slow, animate__repeat-2 or animate__delay-2s into 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 sectionWhat it sets
KeyframesThe 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.
PreviewPlay, Pause, Restart, Reverse, and Reset to start pose, on the canvas only
PresetsReady-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.
TriggerType, plus Threshold and Replay (In view), Reverse on leave (Hover), Start, End and Speed (Scroll)
MotionDuration (50–10,000 ms), Delay (0–10,000 ms), Ease
TargetThis element, Direct children or All descendants, with Stagger (ms), From (first, last, center) and reverse
LoopCount (once, 2, 3, 5, ∞), Mode (Restart or Yoyo), Gap between loops, Direction (reversed)
Counter / Text split / Motion pathShown only when a preset added them
CallbacksJavaScript run on begin, complete, loop, update
Two panel habits break the page:
  • Captured poses hold pixel sizes. + copies about 70 properties from the canvas, width, height, padding, margin and fontSize among them, measured at the canvas width (animate-side-panel.tsx 1414–1427). The site writes them onto the element as inline styles. Tested: a pose holding width: 1180px left 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 to opacity, translateX/Y, scale, rotate and 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.tsx 1454–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:

FieldValuesOn the hosted site
enabledtrue / falsefalse skips it (tested)
presetIdcustom or a preset nameIgnored. The site has no preset list, so { "presetId": "fade-up", "trigger": "load" } with no states or params plays nothing (tested). Always write states.
triggerload, inView, hover, click, scrollTable below
states[{ "style": { … } }, …], at least 2The 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, delaymsPanel defaults: 700, 0
ease"out(2)", "outExpo", "inOutSine", "outBack(1.5)", "linear", or { "kind": "back" | "elastic" | "cubicBezier" | "steps" | "spring", … }animejs v4 names
targetself, children, descendantschildren with stagger cascades (tested: each <li> animated)
stagger, staggerFrom, staggerDirectionms; first / last / center / index; normal / reverse
loop, alternate, reversed, loopDelay, playbackRatenumber or true; booleans; ms; multiplierPassed 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 statesSplits 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 selfRun in the visitor's browser (tested: onComplete set an attribute)
paramsraw animejs parametersUsed 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):

triggerPlaysNote
loadOnce, as soon as the page has finished starting upNot 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.
inViewWhen threshold of the element (default 20%) is on screen; again on each entry if once is falseUntil 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.
hoverOn pointer enter; plays backwards on leave (reverseOnLeave)Phones have no hover. Don't hide anything behind it.
clickOn every click
scrollProgress follows the scroll between start and endSingle 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.

  1. From the repo_find_one result, take data.screens.
  2. In the active screen, set config.animations to the full map. Keep the screen's other config keys (variants, activeVariants).
  3. repo_update on page with { "screens": <the whole array> }. Leave html alone; 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.

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, with animate__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).opacity is 1, and its inline style holds only what the animation set.
  • Scroll to each inView element; it plays once.
  • At 390 px, window.innerWidth is 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.setEmulatedMedia with prefers-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 own prefers-reduced-motion rule, 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-app page-client.tsx 76–83).
  • ?screen= previews play the active screen's animations, not the shown screen's.
  • No play-before-load. load and inView elements 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.
  • presetId alone does nothing on the site, and the panel's preset apply functions don't exist there.
  • scroll with steps does nothing; call steps run nothing.
  • The panel can't author steps, playbackRate or motionPath.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-id that has an animation.
  • Don't write config.animations on root or a parent page; it never plays there.
  • Don't send a partial screens array or a partial config; it replaces what is there.
  • Don't rely on presetId; write states.
  • Don't put width, height, padding, margin or 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.