ExklusivBeta

Motion Budget <mv-motion-budget>

Ein Animations-Regler: Er umschließt einen Bereich (oder die ganze Seite) und gibt ihm ein Bewegungsbudget, den Anteil des Bildschirms, der sich gleichzeitig bewegen darf (standardmäßig 25 %). Viermal pro Sekunde misst er die sichtbare animierte Fläche (CSS und Web Animations, Canvas-Schleifen der Komponenten); bei Überschreitung verlangsamt oder pausiert er die endlosen dekorativen Animationen, die mit der niedrigsten Priorität und die größten zuerst, während Animationen, die durch einen Klick oder Tastendruck ausgelöst werden, immer mit voller Geschwindigkeit laufen. Ein Regler Calm ↔ Lively für Besucher, der auch die Amplituden verkleinert, ein Toggle „Pause all motion“ (WCAG 2.2.2), eine Live-Anzeige und ein Debug-Modus, der jedes bewegte Element beschriftet.

KategorieUtilities
TypWeb 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

  • A page stacks many decorative animations and must cap how much of the screen moves at once
  • Visitors need a Pause all motion control and a Calm to Lively dial to meet WCAG 2.2.2
  • Developers want to debug which elements animate and how much screen area they take

Avoid when

  • The page has only one or two subtle animations; honoring prefers-reduced-motion is enough
  • An essential animation conveys meaning; mark it essential or it may be slowed or paused

Installation

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

KI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["motion-budget"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Kopierte Dateien (inklusive Abhängigkeiten): 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.

Verwendung

Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:

<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

NameTypDefaultDescription
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

NameTypDescription
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.

Diese Seite wurde mit KI übersetzt. Übersetzungsfehler melden