익스클루시브
Motion Budget <mv-motion-budget>
애니메이션 조절기입니다. 영역(또는 페이지 전체)을 감싸 모션 예산, 즉 화면에서 동시에 움직일 수 있는 비율(기본값 25%)을 부여합니다.
작동 방식
애니메이션 조절기입니다. 영역(또는 페이지 전체)을 감싸 모션 예산, 즉 한 번에 움직일 수 있는 화면 비율(기본값 25%)을 부여합니다. 1초에 네 번 화면에 보이는 애니메이션 영역(CSS 및 Web Animations, 컴포넌트의 canvas 루프)을 측정하고, 예산을 초과하면 끝없이 반복되는 장식용 애니메이션을 우선순위가 가장 낮고 가장 큰 것부터 느리게 하거나 일시 정지하며, 클릭이나 키 입력으로 시작된 애니메이션은 항상 최고 속도로 실행됩니다. 진폭도 줄여 주는 방문자용 Calm ↔ Lively 다이얼, “Pause all motion” 토글(WCAG 2.2.2), 실시간 미터, 그리고 움직이는 모든 요소에 라벨을 붙이는 디버그 모드를 제공합니다.
| 카테고리 | 유틸리티 |
|---|---|
| 유형 | Web Component (<mv-motion-budget>) |
| 상태 | 안정 |
| 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
설치
node scripts/add.mjs motion-budget --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["motion-budget"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): 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.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, 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 | 유형 | Default | 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 | 유형 | 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). |
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).