Exclusivebeta

Motion Budget — <mv-motion-budget>

An animation governor: wraps a region (or the whole page) and gives it a motion budget, the share of the screen allowed to move at once (25% by default). Four times a second it measures the visible animated area (CSS and Web Animations, components' canvas loops); over budget, it slows down or pauses the endless decorative animations, lowest priority and largest first, while animations triggered by a click or a key press always run at full speed. Visitor dial Calm ↔ Lively that also shrinks amplitudes, a “Pause all motion” toggle (WCAG 2.2.2), a live meter and a debug mode that labels every moving element.

CategoryUtilities
TypeWeb Component (<mv-motion-budget>)
Statusbeta
Keywordsexclusive, utility, motion, animation, governor, budget, reduced-motion, prefers-reduced-motion, wcag-2.2.2, pause, accessibility, cognitive-load, performance, debug

When to use

Avoid when

Install

node scripts/add.mjs motion-budget --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["motion-budget"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/motion-budget/motion-budget.js, components/motion-budget/motion-budget.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="mb-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #mb-demo .mb-title { display:flex; align-items:baseline; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin:0 0 .5rem }
    #mb-demo .mb-title h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #mb-demo .mb-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    #mb-demo .mb-page { position:relative; overflow:hidden; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); padding-bottom:5.5rem }
    #mb-demo .mb-nav { display:flex; align-items:center; gap:1.25rem; padding:.85rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #mb-demo .mb-logo { display:flex; align-items:center; gap:.5rem; font-weight:700; letter-spacing:-.02em }
    #mb-demo .mb-logo i { width:1.1rem; height:1.1rem; border-radius:50%; background:conic-gradient(from 200deg, var(--mv-accent), var(--mv-info), var(--mv-accent)) }
    #mb-demo .mb-nav nav { display:flex; gap:1.1rem; margin-inline-start:auto; color:var(--mv-fg-muted); font-size:.8125rem }
    #mb-demo .mb-hero { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(0,1fr); gap:1.5rem; align-items:center; padding:1.75rem 1.25rem 1.25rem }
    #mb-demo .mb-eyebrow { display:inline-flex; align-items:center; gap:.55rem; margin:0 0 .9rem; padding:.25rem .7rem .25rem .55rem; border:1px solid var(--mv-border); border-radius:999px; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
    #mb-demo .mb-live { position:relative; width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-success) }
    #mb-demo .mb-live::after { content:""; position:absolute; inset:0; border-radius:50%; background:var(--mv-success); animation:mb-ping 1.6s var(--mv-ease-out) infinite }
    #mb-demo h2 { margin:0 0 .75rem; font-size:clamp(1.6rem,1.1rem + 2vw,2.35rem); line-height:1.08; letter-spacing:-.03em }
    #mb-demo .mb-shimmer { background:linear-gradient(100deg, var(--mv-fg) 0 38%, color-mix(in oklab, var(--mv-accent) calc(var(--mv-motion) * 100%), var(--mv-fg)) 50%, var(--mv-fg) 62% 100%) 0 0 / 260% 100%; -webkit-background-clip:text; background-clip:text; color:transparent; animation:mb-shimmer 3.2s linear infinite }
    #mb-demo .mb-copy p { margin:0 0 1.25rem; color:var(--mv-fg-muted); font-size:.9375rem; line-height:1.55; max-width:30rem }
    #mb-demo .mb-cta { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center }
    #mb-demo #mb-book { position:relative }
    #mb-demo .mb-ring { position:absolute; inset:0; border-radius:inherit; box-shadow:0 0 0 2px var(--mv-accent); opacity:0; pointer-events:none }
    #mb-demo .mb-art { position:relative; min-height:17rem; border-radius:var(--mv-radius-lg); overflow:hidden; background:var(--mv-bg-subtle); border:1px solid var(--mv-border) }
    #mb-demo .mb-blob { position:absolute; border-radius:50%; filter:blur(18px); opacity:.75; animation:mb-float 7s var(--mv-ease-in-out) infinite alternate }
    #mb-demo .b1 { width:15rem; height:15rem; left:-3rem; top:-4rem; background:color-mix(in oklab, var(--mv-accent) 70%, transparent) }
    #mb-demo .b2 { width:12rem; height:12rem; right:-3rem; top:3rem; background:color-mix(in oklab, var(--mv-info) 65%, transparent); animation-duration:9s; animation-delay:-3s }
    #mb-demo .b3 { width:10rem; height:10rem; left:35%; bottom:-4rem; background:color-mix(in oklab, var(--mv-success) 55%, transparent); animation-duration:8s; animation-delay:-5s }
    #mb-demo .mb-stat { position:absolute; left:1.25rem; bottom:1.25rem; display:grid; gap:.15rem; padding:.75rem .95rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:color-mix(in oklab, var(--mv-surface-raised) 88%, transparent); box-shadow:var(--mv-shadow-md); font-size:.75rem; color:var(--mv-fg-muted) }
    #mb-demo .mb-stat strong { color:var(--mv-fg); font-size:1.25rem; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
    #mb-demo .mb-badge { position:absolute; right:1.1rem; top:1.1rem; font:700 .62rem/1 var(--mv-font-sans); letter-spacing:.14em; text-transform:uppercase; color:var(--mv-fg-on-accent); background:var(--mv-accent); box-shadow:var(--mv-shadow-lg) }
    #mb-demo .mb-logos { display:grid; grid-template-columns:auto minmax(0,1fr); gap:1.25rem; align-items:center; padding:.5rem 1.25rem 0 }
    #mb-demo .mb-logos > span { color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.08em; text-transform:uppercase; white-space:nowrap }
    #mb-demo .mb-brand { padding-inline:1.1rem; color:var(--mv-fg-muted); font-weight:700; font-size:1rem; letter-spacing:-.02em; white-space:nowrap }
    #mb-demo .mb-hint { margin:.75rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5; text-align:center; text-wrap:balance }
    @keyframes mb-float {
      from { translate:0 0; scale:1 }
      to { translate:calc(2.25rem * var(--mv-motion)) calc(-1.75rem * var(--mv-motion)); scale:calc(1 + .12 * var(--mv-motion)) }
    }
    @keyframes mb-shimmer { from { background-position:100% 0 } to { background-position:0 0 } }
    @keyframes mb-ping { from { scale:1; opacity:.7 } to { scale:calc(1 + 1.6 * var(--mv-motion)); opacity:0 } }
    @media (max-width:40rem) {
      #mb-demo .mb-hero { grid-template-columns:minmax(0,1fr) }
      #mb-demo .mb-art { min-height:12rem }
      #mb-demo .mb-nav nav { display:none }
      #mb-demo .mb-logos { grid-template-columns:minmax(0,1fr); gap:.4rem }
      #mb-demo .mb-page { padding-bottom:13rem }
    }
    @media (prefers-reduced-motion:reduce) { #mb-demo :is(.mb-blob,.mb-shimmer), #mb-demo .mb-live::after { animation:none } }
    :is(#mb-demo [data-motion="reduce"], [data-motion="reduce"] #mb-demo) :is(.mb-blob,.mb-shimmer), :is(#mb-demo [data-motion="reduce"], [data-motion="reduce"] #mb-demo) .mb-live::after { animation:none }
  </style>

  <div class="mb-title"><h3>Motion budget</h3><span>This frame allows 10% of the screen to move at once</span></div>

  <mv-motion-budget id="mb-gov" budget="0.1" controls debug>
    <div class="mb-page">
      <header class="mb-nav">
        <span class="mb-logo"><i aria-hidden="true"></i>Orbit</span>
        <nav aria-label="Demo"><span>Product</span><span>Pricing</span><span>Customers</span></nav>
      </header>

      <section class="mb-hero">
        <div class="mb-copy">
          <p class="mb-eyebrow"><span class="mb-live" aria-hidden="true"></span>128 teams online</p>
          <h2 class="mb-shimmer">Lively pages, without the motion sickness.</h2>
          <p>Orbit animates your product pages, then lets every visitor choose how much motion feels right.</p>
          <div class="mb-cta">
            <button class="mv-button" type="button" id="mb-book"><span class="mb-ring" aria-hidden="true"></span><span class="mb-book-label">Book a demo</span></button>
            <button class="mv-button" type="button" data-variant="ghost">Watch the video</button>
          </div>
        </div>

        <div class="mb-art" aria-hidden="true">
          <span class="mb-blob b1"></span>
          <span class="mb-blob b2"></span>
          <span class="mb-blob b3"></span>
          <div class="mb-stat">Visits today <strong>12,480</strong></div>
          <mv-circular-text class="mb-badge" text="Launch offer • 30% off • " duration="14000" hover="none"></mv-circular-text>
        </div>
      </section>

      <div class="mb-logos">
        <span>Trusted by</span>
        <mv-marquee speed="36" fade data-motion-source="speed" aria-label="Customers">
          <span class="mb-brand">Northwind</span><span class="mb-brand">Kappa Labs</span><span class="mb-brand">Harbor</span><span class="mb-brand">Atelier 9</span><span class="mb-brand">Lumen</span><span class="mb-brand">Sakura Pay</span><span class="mb-brand">Velo &amp; Co</span>
        </mv-marquee>
      </div>
    </div>
  </mv-motion-budget>

  <p class="mb-hint">Over budget, the largest decorative animations slow down first; at Calm everything slows and the spinning badge, which can only stop, pauses. “Book a demo” always responds at full speed. Slide from Calm to Lively, or pause all motion.</p>

  <script type="module">
    const book = document.getElementById("mb-book");
    const label = book.querySelector(".mb-book-label");
    let timer = 0;
    // Feedback: must stay smooth whatever the budget (started right after the click).
    book.addEventListener("click", () => {
      const still = matchMedia("(prefers-reduced-motion: reduce)").matches || book.closest('[data-motion="reduce"]');
      if (!still) {
        book.animate([{ scale: 1 }, { scale: 0.94, offset: 0.25 }, { scale: 1.04, offset: 0.6 }, { scale: 1 }], { duration: 600, easing: "cubic-bezier(0.2, 0, 0, 1)" });
        book.querySelector(".mb-ring").animate([{ opacity: 0.7, scale: 1 }, { opacity: 0, scale: 1.35 }], { duration: 700, easing: "cubic-bezier(0.22, 1, 0.36, 1)" });
      }
      label.textContent = "You're booked!";
      clearTimeout(timer);
      timer = setTimeout(() => { label.textContent = "Book a demo"; }, 2200);
    });
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
budgetnumber (0–1) | percentage0.25Maximum share of the viewport moving at the same time while the dial is on “Moderate”. Accepts “0.2” or “20%”.
modeslow | pauseslowWhat happens to a decorative animation over budget: slowed down (speed × 0.35) or paused, then released one second after the budget frees up.
controlsfloating | inlineShows the visitor dial. No value or floating: floating panel stuck to the bottom of the region (bottom right of the screen with target="document"); inline: panel at the top of the region.
debugbooleanfalseOutlines every moving element with its verdict (“decorative · slowed”, “feedback · allowed”, “transient · tolerated”…) and its share of the screen.
persiststringRemembers the dial and the pause in localStorage under this key (empty value: the element's id or the page path).
targetregion | documentregionGoverns the element's content, or the whole page. In document mode, pausing also sets data-motion="reduce" on <html>, which stops every frameLoop() of the library.
levelnumber (0–1)0.5Initial dial: 0 = Calm (zero budget, amplitudes at 0), 0.5 = Moderate (the budget), 1 = Lively (no limit). Ignored when the visitor already chose (persist); 0 when the system asks for reduced motion.
data-motion-prioritynumber | essential0On an animated element or an ancestor: lower priorities are throttled first; essential is never throttled (essential motion in the WCAG sense).
data-motion-feedbackbooleanOn an element or an ancestor: its animations are always treated as feedback.
data-motion-source"" | speed | noneDeclares a requestAnimationFrame loop the Web Animations API cannot see (canvas…). speed: the speed attribute is a speed multiplier (mv-marquee, mv-particles, mv-waves…) and will be scaled down; empty: only the mv-motion-throttle event is sent; none: ignore. Library components with a paused attribute (shaders, mv-circular-text, mv-morphing-text…) are detected automatically.
data-motion-throttleslow | pauseSet by the component on a throttled source (read-only, styling hook).

Properties

NameTypeDescription
usagenumberShare of the viewport moving right now (0–1), weighted by speed: a slowed animation counts for 0.35, a paused one for 0.
demandnumberShare that would move without the governor.
throttled{ target, mode, area, kind }[]Elements currently slowed or paused (kind: animation or source).
levelnumberCurrent dial (0–1), writable.
pausedbooleanTrue while everything is paused (read-only, see pauseAll).

Methods

NameDescription
pauseAll() / resumeAll()Pauses (or resumes) every endless animation and loop in the region, including those that appear later.
setLevel(level)Sets the dial from Calm (0) to Lively (1): the effective budget and --mv-motion follow, smoothly.

Events

NameDescription
mv-motion-budgetOn the element, on every sample where usage moves by at least 1 point, the budget changes or the set of throttled elements changes. detail: { usage, demand, budget, throttled }.
mv-motion-throttleOn a source (data-motion-source or detected component), bubbles: detail { mode: "slow" | "pause" | null, rate }. null = released. Listen to it to throttle a custom loop.

Content structure

NameDescription
(content)The governed region: any animated content, including content added later.

CSS classes

NameDescription
mv-motion-budget-panelControl panel (role=group): dial, meter, pause button.
mv-motion-budget-range / -spark / -pauseNative slider, meter sparkline and toggle button.
mv-motion-budget-dockSticky row carrying the floating panel.
mv-motion-budget-box / -tagDebug-mode outlines and labels (data-kind, data-state, data-place).

CSS variables

NameDescription
--mv-motionSet on the region (or <html>) from the dial: 1 from Moderate up, down to 0 at Calm, with a smooth transition. Multiply your amplitudes by it.
--mv-motion-budget-ampRegistered property (@property) that carries the --mv-motion transition.

Accessibility

The dial is a real <input type="range"> named by the visible “Animations” title (arrows, Home/End, Page Up/Down), with aria-valuetext such as “Moderate: up to 20% of the screen in motion” and the meter as its description. “Pause all motion” is a toggle button (aria-pressed) with a constant label that meets WCAG 2.2.2 (Pause, Stop, Hide): every endless animation and loop stops, new ones too, and data-motion="reduce" is set on the region so library components switch to their static mode. The meter is an aria-hidden chart backed by visible text (“Motion on screen 18%”, “Budget 20% · 3 slowed”); no live region chatters four times a second. When the system asks for reduced motion, the dial starts at Calm. Nothing traps focus; the panel is neither modal nor obscuring. Feedback (animations starting within 400 ms of a click or key press) is never throttled, so visitors always see the effect of their action. Cost: 4 Hz sampling (setInterval, no rAF loop), suspended while the tab is hidden or the region is off screen; areas read from an IntersectionObserver (getBoundingClientRect only in debug mode); everything is restored when the element is removed.