Exclusif
Motion Budget <mv-motion-budget>
Un régulateur d’animation : enveloppe une zone (ou toute la page) et lui attribue un budget de mouvement, la part de l’écran autorisée à bouger simultanément (25 % par défaut).
Fonctionnement
Un régulateur d’animations : il enveloppe une zone (ou toute la page) et lui attribue un budget de mouvement, la part de l’écran autorisée à bouger en même temps (25 % par défaut). Quatre fois par seconde, il mesure la surface animée visible (CSS et Web Animations, boucles canvas des composants) ; au-delà du budget, il ralentit ou met en pause les animations décoratives sans fin, les moins prioritaires et les plus grandes d’abord, tandis que les animations déclenchées par un clic ou une touche tournent toujours à pleine vitesse. Réglage visiteur Calm ↔ Lively qui réduit aussi les amplitudes, bascule « Pause all motion » (WCAG 2.2.2), jauge en direct et mode débogage qui étiquette chaque élément en mouvement.
| Catégorie | Utilitaires |
|---|---|
| Type | Web Component (<mv-motion-budget>) |
| Statut | stable |
| Keywords | exclusive, 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/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["motion-budget"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/i18n.js, core/motion.js, components/motion-budget/motion-budget.js, components/motion-budget/motion-budget.css.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<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>Loomwell</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>Loomwell 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">Fernhill</span><span class="mb-brand">Quillon Labs</span><span class="mb-brand">Tidewater Co</span><span class="mb-brand">Atelier 9</span><span class="mb-brand">Brightmoor</span><span class="mb-brand">Mirabel Pay</span><span class="mb-brand">Velo & 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
| Name | Type | Par défaut | Description |
|---|---|---|---|
budget | number (0-1) | percentage | 0.25 | Maximum share of the viewport moving at the same time while the dial is on “Moderate”. Accepts “0.2” or “20%”. |
mode | slow | pause | slow | What happens to a decorative animation over budget: slowed down (speed × 0.35) or paused, then released one second after the budget frees up. |
controls | floating | inline | Shows 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. | |
debug | boolean | false | Outlines every moving element with its verdict (“decorative · slowed”, “feedback · allowed”, “transient · tolerated”…) and its share of the screen. |
persist | string | Remembers the dial and the pause in localStorage under this key (empty value: the element's id or the page path). | |
target | region | document | region | Governs 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. |
level | number (0-1) | 0.5 | Initial 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-priority | number | essential | 0 | On an animated element or an ancestor: lower priorities are throttled first; essential is never throttled (essential motion in the WCAG sense). |
data-motion-feedback | boolean | On an element or an ancestor: its animations are always treated as feedback. | |
data-motion-source | "" | speed | none | Declares 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-throttle | slow | pause | Set by the component on a throttled source (read-only, styling hook). |
Properties
| Name | Type | Description |
|---|---|---|
usage | number | Share of the viewport moving right now (0-1), weighted by speed: a slowed animation counts for 0.35, a paused one for 0. |
demand | number | Share that would move without the governor. |
throttled | { target, mode, area, kind }[] | Elements currently slowed or paused (kind: animation or source). |
level | number | Current dial (0-1), writable. |
paused | boolean | True while everything is paused (read-only, see pauseAll). |
strings | Partial<Record<string, string>> | Overrides for the default text (en-US): title (dial title), calm, moderate, lively (level names), valueText (dial aria-valuetext, {level} {detail}), detailNoLimit, detailPaused, detailSlowed, detailBudget ({percent}) (dial detail), motionOnScreen (meter label, {percent}), allPaused, noLimit, budget ({percent}), countPaused, countSlowed ({n}) (meter status), pauseAll (pause button), panelPage, panelSection (panel name for target="document" or a region). The debug overlay stays English (developer tool). App-wide translations: setStrings() from core/i18n.js, under the key "motion-budget". Can be set before the element is defined. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-motion-budget | On 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-throttle | On 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
| Name | Description |
|---|---|
(content) | The governed region: any animated content, including content added later. |
CSS classes
| Name | Description |
|---|---|
mv-motion-budget-panel | Control panel (role=group): dial, meter, pause button. |
mv-motion-budget-range / -spark / -pause | Native slider, meter sparkline and toggle button. |
mv-motion-budget-dock | Sticky row carrying the floating panel. |
mv-motion-budget-box / -tag | Debug-mode outlines and labels (data-kind, data-state, data-place). |
CSS variables
| Name | Description |
|---|---|
--mv-motion | Set 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-amp | Registered 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 once for a newly seen element, and in debug mode); everything is restored when the element is removed. Forced colors: the dial, sparkline and pressed toggle use system colors; debug tags stay inside the region. Known limits: document.getAnimations() is sampled 4 times per second, so a burst shorter than 250 ms can go unmeasured; requestAnimationFrame loops that the Web Animations API cannot see are governed only when marked data-motion-source or when they listen to mv-motion-throttle (do not mark a component whose speed is an interval rather than a rate, such as mv-scramble-text); each element’s visible area is read in 10% steps (IntersectionObserver thresholds).