Verification

The motion check

The motion check flags likely mistakes in an animation: a spring cut off mid-flight, a bounce the browser clamps away, travel at a constant, mechanical speed, a list that takes too long to arrive, or an export that's longer than it needs to be.

Where it is
The check button beside Export counts what it found, most severe first, and shows a shield when there’s nothing to flag. ⌘K “Check motion…” opens it too. Show goes straight to the element and keyframe to blame, and Turn off switches a rule off for this document, with its reason one hover away.
Every rule can be turned off
Each rule has a reason and an off switch, and none fires on something that’s just unusual. The table says what each one leaves alone. There’s deliberately no rule against two elements moving the same property in opposite directions, since that’s how doors, curtains and cross-slides work.
The same rules in CI
npm run check -- scene.json --lint, run from the verve repo, applies these rules to a saved scene with no browser. Errors fail the run, and --strict fails it on warnings too. A rule switched off is stored on the scene, so it’s off in CI as well as in the editor.
RuleSeverityWhy it matters, and what it leaves alone
A motion path and x/y on one elementpath-and-positionerrorCSS applies a motion path and then the transform, so a path and x/y keyframes stack: the element travels the path offset by wherever x and y put it. The editor refuses to create this; a scene edited by hand or generated elsewhere can still contain it.Not for: An element with a path and no x/y keyframes, or x/y and no path.
A spring cut off mid-flightcut-springwarningThe engine stops a spring dead at the next keyframe, so a spring still moving there visibly snaps — and every export reproduces the snap faithfully. It usually means the next keyframe was placed before the spring could finish.Not for: A spring whose remaining movement at the cut is under 2% of its travel: nobody can see that.
A bounce the browser clamps awayclamped-springwarningOpacity cannot go past 0 or 1, and a filter cannot go below its floor — a blur of −3px is not a blur. A spring that overshoots there is clamped flat, so the bounce you chose never shows, and the curve has a flat spot where it would have been.Not for: Springs that stay inside the property's range, and transforms, which have no range to hit.
Travel at a constant speedlinear-travelwarningNothing physical moves at a constant speed: it accelerates, and it slows to a stop. Linear movement reads as mechanical — easeOut for something arriving, easeIn for something leaving, or a spring.Not for: Rotation (a spinner should be linear), scale X or Y on their own (a progress bar), opacity and color (a linear fade is fine), moves under 4px or 150ms, and motion paths.
A stagger that keeps the end of the list waitinglong-staggerwarningPeople read a list faster than a slow stagger reveals it, so the last items arrive after attention has moved on. The motion system's steps are 60–160ms; past about 1s from first start to last, shorten the step or stagger only the first few.Not for: Staggers whose last item starts within 1s of the first, and children that are not running the same animation.
A stagger that is almost evenuneven-staggerinfoAn even step exports as ONE stagger in every dialect — staggerChildren, a single GSAP tween, one shared @keyframes. A few milliseconds off and it exports as a separate animation per element instead: longer, and harder to change later.Not for: Timing more than a quarter of a step off even — that is choreography, not a slip — and runs already even enough to export as one.
A stagger against document orderbackwards-staggerinfoMotion's staggerChildren and GSAP's stagger both space elements in document order, so a run that plays last-to-first cannot be one of them — it exports as a separate animation per element, in every dialect, to stay correct.Not for: Runs that play in the order the elements appear, and timing that is not a run at all.
Movement large enough to need a reduced-motion fallbacklarge-motioninfoLarge movement, spins and zooms can make people with vestibular disorders unwell; operating systems let them ask for less, and every export here can honour it. This is a reminder to turn on Reduced motion when you export — it is derived for you in every dialect.Not for: Travel under 192px (four times the motion system's longest entrance), turns under 180°, scale changes under 2×, and fades and color, which reduced motion keeps.
A large blur that animateslarge-blurinfoChrome keeps a filter that moves pixels off its compositor, so an animated blur or drop shadow is restyled on the main thread every frame — where a busy page makes it stutter — and drawn again every frame on top. Measured in Chrome 153: rendering in software took 1.7× as long for a 24px blur as for a 4px one, and 2.5× at 40px, while brightness, saturate and contrast ran on the compositor with no main-thread work at all.Not for: A blur or a drop shadow's blur under 24px, a blur that does not change, and brightness, saturate and contrast.
What an export costs the browser
Every export states its render cost under the code: what kind of work the browser does each frame to run it, for the element and its picks or for the whole scene, in the dialect you chose. It’s always shown.
How the tiers were measured
The tiers come from Chrome traces, one card per property family, driven the same way each export drives it: a CSS animation, what Motion hands the browser, a style written from script. Software rendering and a real GPU put every family in the same tier.
In CI
npm run check -- scene.json --max-cost=style --dialect=motion exits 1 when the scene costs more than that. The cost depends on the code you ship, so a threshold needs a dialect.
TierWhat happens every frame
compositor onlycompositorThe browser's compositor runs it: no work on the main thread, nothing repainted.
restyles every framestyleThe main thread recalculates its style every frame, so a busy page can make it stutter. Nothing is repainted.
repaints every framerepaintIts pixels are painted again every frame, a cost that grows with the element's size.
Every frame, animatingMotionCSSGSAPCSS, on scroll
x, yrepaintscompositorrepaintsrestyles
scale, scale x, scale yrepaintscompositorrepaintsrestyles
rotaterepaintscompositorrestylesrestyles
rotate x, rotate yrestylescompositorrestylesrestyles
skew x, skew yrepaintscompositorrepaintsrestyles
opacitycompositorcompositorrepaintsrestyles
blurrestylesrestylesrepaintsrestyles
brightness, saturate, contrastcompositorcompositorrepaintsrestyles
drop shadowrestylesrestylesrepaintsrestyles
along pathrepaintsrepaintsrepaintsrepaints
backgroundrepaintsrepaintsrepaintsrepaints
text colorrepaintsrepaintsrepaintsrepaints
borderrepaintsrepaintsrepaintsrepaints

Worth knowing. A blur or drop shadow never runs on the compositor, because Chrome keeps a filter that moves pixels off it, so the motion check mentions one that animates past 24px. Motion runs x, scale and rotate from its own frame loop, where an element without a layer is repainted every frame; it hands opacity, filters and background color to the browser. GSAP runs everything from script, and its automatic translate3d spares rotation but not scale or skew. A scroll-driven CSS animation restyles on every scroll step. Where it stops: measured in Chrome 153 only. Each family was measured alone, so an element is reported at its most expensive property. A tier says what kind of work happens, not how long it takes, which depends on the element’s size. And a view transition isn’t measured, so a Navigation export says so instead of guessing.