# Animations and Managed Motion

> Build CSS, GSAP, Canvas, WebGL, Three.js, Lottie, and Rive animation within storefront performance boundaries

Canonical URL: https://fa7e86e3d553:3005/pages/animations

Lexsis storefront animation is capability-based. It does not require a named
scene, a hardcoded animation template, or a custom React island for each visual
idea.

Use the lightest option that can express the design:

| Need | Use |
|---|---|
| Hover, focus, or a simple entrance | CSS transitions and shared keyframes |
| A reusable common reveal | `data-behavior="gsap-*"` |
| A custom timeline or interaction | Managed motion with WAAPI or GSAP |
| Procedural graphics | Managed Canvas 2D |
| Shaders and custom rendering | Managed WebGL |
| A 3D product or environment | Managed Three.js |
| Designer-authored vector or state-machine animation | Managed Lottie or Rive |
| Stateful reusable commerce UI | An island |

Custom animation belongs in a
`<script type="application/lexsis-motion">` block associated with its section.
The compiler stores it separately from legacy section JavaScript and validates
its declared capabilities before the page can be published.

## Minimal managed-motion example

```html
<!-- section: product-story -->
<section class="product-story">
  <article class="story-card">Material one</article>
  <article class="story-card">Material two</article>
</section>

<script
  type="application/lexsis-motion"
  data-motion-id="story-reveal"
  data-capabilities="visibility waapi"
  data-mode="scroll"
  data-importance="decorative"
  data-reduced-motion="static"
>
({ dom, visibility, waapi }) => {
  const cards = dom.queryAll(".story-card");
  const observer = visibility.observe(cards, (entries, instance) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;
      waapi.animate(
        entry.target,
        [
          { opacity: 0.25, transform: "translateY(24px)" },
          { opacity: 1, transform: "translateY(0)" }
        ],
        {
          duration: 620,
          easing: "cubic-bezier(.16,1,.3,1)",
          fill: "both"
        }
      );
      instance.unobserve(entry.target);
    });
  }, { threshold: 0.2 });

  return () => observer.disconnect();
}
</script>
```

The script body must be a function expression. Return a cleanup function when
the module creates resources that need explicit disposal.

## Module attributes

| Attribute | Values | Purpose |
|---|---|---|
| `data-motion-id` | A unique identifier | Identifies the module in validation and runtime diagnostics |
| `data-capabilities` | Space- or comma-separated capabilities | Grants only the runtime APIs the module uses |
| `data-mode` | `entrance`, `interaction`, `scroll`, `continuous` | Describes how the work runs |
| `data-importance` | `essential`, `decorative` | Records whether the motion is required to understand the section |
| `data-reduced-motion` | `static`, `simplified` | Chooses the reduced-motion behavior |

Defaults are `entrance`, `decorative`, and `static`. Declare capabilities
explicitly; undeclared capability use is a compile error.

## Runtime context

These APIs are always available:

| API | Use |
|---|---|
| `root` | The current section root |
| `dom.query()` / `dom.queryAll()` | Scoped element lookup |
| `dom.on()` | Managed section-scoped event listeners |
| `dom.create()` / `dom.append()` | Bounded DOM creation with cleanup |
| `scheduler.frame()` | One managed animation frame |
| `scheduler.loop()` | A managed continuous frame loop |
| `scheduler.timeout()` / `scheduler.interval()` | Managed timers |
| `scheduler.addCleanup()` | Register explicit resource cleanup |
| `quality` | Device-adjusted `tier`, `dpr`, and `fps` |
| `preferences` | Reduced motion, save-data, color scheme, and contrast |
| `assets` | Access assets declared in section markup |

Declared capabilities add these APIs:

| Capability | Runtime API | Typical use |
|---|---|---|
| `waapi` | `waapi.animate()` | Entrances, transforms, opacity, filters |
| `svg` | `svg.create()` / `svg.set()` | Paths, masks, procedural SVG |
| `gsap` | `gsap.load()` / `gsap.withContext()` | Timelines and ScrollTrigger |
| `scroll` | `scroll.on()` / `scroll.progress()` | Scroll-linked choreography |
| `pointer` | `pointer.onMove()` / `onEnter()` / `onLeave()` | Pointer response |
| `resize` | `resize.observe()` | Responsive canvas and scene sizing |
| `visibility` | `visibility.observe()` | Viewport-triggered work |
| `canvas` | `canvas.context2d()` / `canvas.fit()` | Managed Canvas 2D |
| `webgl` | `webgl.context()` | Raw WebGL 1 or 2 |
| `three` | `three.load()` | Lazy-loaded Three.js |
| `lottie` | `assets.lottie.mount()` | Lottie JSON animation |
| `rive` | `assets.rive.mount()` | Rive animation |
| `video` | `media.source()` | Declared video assets |
| `events` | `events.on()` / `events.emit()` | Storefront runtime events |

The runtime removes undeclared capability APIs.

## Canvas 2D example

```html
<!-- section: particle-field -->
<section class="particle-field">
  <canvas class="particle-canvas" aria-hidden="true"></canvas>
  <h2>Move through the field</h2>
</section>

<script
  type="application/lexsis-motion"
  data-motion-id="particle-field"
  data-capabilities="canvas pointer resize"
  data-mode="continuous"
  data-importance="decorative"
  data-reduced-motion="static"
>
({ canvas, pointer, resize, scheduler, quality }) => {
  const surface = canvas.context2d(".particle-canvas");
  let pointerX = 0.5;
  let pointerY = 0.5;

  const stopPointer = pointer.onMove((event) => {
    const rect = surface.canvas.getBoundingClientRect();
    pointerX = (event.clientX - rect.left) / Math.max(1, rect.width);
    pointerY = (event.clientY - rect.top) / Math.max(1, rect.height);
  }, surface.canvas);

  resize.observe(surface.canvas, surface.fit);

  const stopLoop = scheduler.loop((time) => {
    const { width, height } = surface.fit();
    const context = surface.context;
    context.clearRect(0, 0, width, height);
    context.fillStyle = "rgba(255, 184, 72, .65)";
    context.beginPath();
    context.arc(
      width * pointerX,
      height * pointerY,
      18 + Math.sin(time * 0.002) * 4,
      0,
      Math.PI * 2
    );
    context.fill();
  }, { fps: quality.fps });

  return () => {
    stopPointer();
    stopLoop();
  };
}
</script>
```

`surface.fit()` uses the runtime DPR cap. Do not size a canvas directly from
the device pixel ratio.

## Three.js product example

Three.js is loaded only when the module mounts. The runtime reserves and tracks
the WebGL context.

```html
<!-- section: product-object -->
<section class="product-object">
  <canvas class="product-canvas" aria-label="Interactive product model"></canvas>
  <div class="product-fallback">Product image or static illustration</div>
</section>

<script
  type="application/lexsis-motion"
  data-motion-id="product-object"
  data-capabilities="three resize"
  data-mode="interaction"
  data-importance="decorative"
  data-reduced-motion="static"
>
async ({ dom, three, resize, scheduler, quality }) => {
  const THREE = await three.load();
  const canvas = dom.query(".product-canvas");
  const renderer = new THREE.WebGLRenderer({
    canvas,
    alpha: true,
    antialias: quality.tier !== "low"
  });
  renderer.setPixelRatio(quality.dpr);

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100);
  camera.position.z = 7;

  const geometry = new THREE.IcosahedronGeometry(1.4, 2);
  const material = new THREE.MeshPhysicalMaterial({
    color: 0x111111,
    roughness: 0.18,
    clearcoat: 1
  });
  const object = new THREE.Mesh(geometry, material);
  scene.add(object);
  scene.add(new THREE.HemisphereLight(0xffffff, 0x222222, 3));

  const fit = () => {
    const rect = canvas.getBoundingClientRect();
    renderer.setSize(rect.width, rect.height, false);
    camera.aspect = rect.width / Math.max(1, rect.height);
    camera.updateProjectionMatrix();
  };
  fit();
  resize.observe(canvas, fit);

  const stopLoop = scheduler.loop(() => {
    object.rotation.y += 0.004;
    renderer.render(scene, camera);
  }, { fps: quality.fps });

  return () => {
    stopLoop();
    renderer.forceContextLoss();
    renderer.dispose();
    geometry.dispose();
    material.dispose();
  };
}
</script>
```

For drag rotation, bind `pointerdown`, `pointermove`, `pointerup`, and
`pointercancel` through `dom.on()` on the canvas. Use pointer capture so the
interaction remains scoped to the section.

## GSAP example

Use `gsap.withContext()` so timelines are scoped, paused when the section is
offscreen, and reverted during cleanup.

```html
<script
  type="application/lexsis-motion"
  data-motion-id="hero-timeline"
  data-capabilities="gsap"
  data-mode="entrance"
  data-importance="decorative"
  data-reduced-motion="simplified"
>
async ({ gsap }) => {
  return gsap.withContext((runtime) => {
    const timeline = runtime.timeline();
    timeline
      .from(".hero-title", { y: 36, opacity: 0, duration: 0.8 })
      .from(".hero-copy", { y: 18, opacity: 0, duration: 0.55 }, "-=0.4");
    return () => timeline.kill();
  });
}
</script>
```

GSAP and ScrollTrigger use pinned lazy-loaded runtime assets. Do not add a GSAP
CDN script to the page.

## Lottie, Rive, images, and JSON

Remote motion resources must be declared in the section markup with an
absolute HTTPS URL:

```html
<div
  data-motion-asset="gift-reveal"
  data-src="https://cdn.example.com/gift-reveal.json"
  hidden
></div>
```

Read them by declared name:

```javascript
({ assets }) => assets.json("gift-reveal")
```

Available asset APIs are:

- `assets.url(name)`
- `assets.json(name)`
- `assets.image(name)`
- `assets.lottie.mount(name, container, options)`
- `assets.rive.mount(name, canvas, options)`

Motion code cannot make arbitrary network requests.

## Progressive enhancement

Static content must remain useful if animation never starts.

- Render meaningful copy, images, SVG, or a lightweight fallback in the HTML.
- Keep content visible by default.
- Apply hidden or transformed starting states from the running animation, not
  as a permanent CSS dependency.
- Never leave an empty fixed-height canvas as the only representation of
  essential content.
- Use `data-reduced-motion="static"` when the static version communicates the
  same information.
- Do not animate CTAs, pricing, variant state, or other commerce controls in a
  way that delays interaction.

If a module exceeds a runtime boundary, Lexsis disables that module and
preserves the section's static content.

## Performance boundaries

These limits protect the page without prescribing its design:

- Motion source is limited to 100 KiB per page.
- A section may create at most two managed continuous loops.
- A page may create at most four managed continuous loops.
- A page may use at most two managed WebGL or Three.js contexts.
- Device quality tiers cap DPR at 1, 1.5, or 2.
- Device quality tiers cap frame rate at 30, 45, or 60 fps.
- Offscreen and hidden sections pause managed loops and timers.
- Repeated callbacks over 50 ms disable the offending module.
- Excessive DOM growth or mutation activity disables the offending module.
- Runtime resources are cleaned up when sections are replaced or the page
  exits.

Compose related drawing and rendering into one `scheduler.loop()` instead of
starting a loop for every object.

## Compiler safety rules

Managed motion cannot directly use:

- `window`, `document`, or other global browser escape hatches
- `fetch`, WebSockets, workers, or browser storage
- raw `setTimeout`, `setInterval`, or `requestAnimationFrame`
- raw mutation, intersection, or resize observers
- dynamic imports or dynamic code execution
- programmatic clicks
- island internals
- unbounded JavaScript loops

Use the managed context equivalents. The compiler reports unsafe or undeclared
behavior before publishing.

## Shared CSS keyframes

For simple animation, the renderer still provides:

| Name | Effect |
|---|---|
| `fadeUp` | Fade and rise |
| `fadeIn` | Opacity reveal |
| `scaleIn` | Scale and fade |
| `slideInLeft` | Enter from the left |
| `slideInRight` | Enter from the right |
| `marquee` | Continuous horizontal movement |
| `float` | Gentle vertical movement |
| `shimmer` | Gradient sweep |
| `wordFade` | Per-word reveal |
| `pulseRing` | Expanding attention ring |

Always add a reduced-motion rule:

```css
@media (prefers-reduced-motion: reduce) {
  .decorative-motion {
    animation: none;
    transition: none;
  }
}
```

## When to create an island

Create an island when the feature owns reusable state or commerce behavior:
variant selection, cart state, a reusable configurator, or a component used
across many pages.

Do not create an island only to hold a one-off timeline, shader, particle
field, 3D composition, or scroll effect. Managed motion is designed for those
cases.

## Related

- [Page Source Contract](/pages/schema) — Section and script placement
- [Theming](/pages/theming) — Page design tokens
- [Islands](/islands) — Stateful reusable components
- [Page Safety](/tools/page-safety) — Validation and publishing safeguards
