check_motion Rules

The twenty-one rules check_motion enforces, what each one catches, and why none of them show up as build errors.

check_motion reads a file of animation code and reports what is wrong with it. Everything it looks for is valid JavaScript that type-checks, renders correctly, and still produces a poor motion system. That is the whole selection criterion: a mistake tsc would catch does not belong here.

Every finding names the file, the line, the rule and the fix. It reads any animation code, not only ours: a component you or your agent wrote on the runtime is exactly what it is for. Where the runtime has a primitive that makes the mistake impossible, the fix names it.

Running it

Through an agent with the MCP server connected, ask in plain language:

Check this file with check_motion.

It takes a path or the file you are working in. There is no separate install and no config: the rules ship inside the CLI, and the MCP Server page covers connecting it to your editor.

Severities

Error means the code is broken, not merely expensive. It will throw, or it will silently discard something you asked for.

Warning means it works and costs more than it should, or it fails in a situation you have not hit yet.

Info means what is written is not wrong. It is incomplete, or it depends on something this file cannot see.

Errors

RuleWhat it catchesThe fix
client-boundaryA client-only hook in a file with no client directive.Add "use client" as the first line.
ssr-module-scopeA browser-only value read at module scope. It is undefined on the server and throws at import time.Move the access inside a component, an effect, or an event handler.
orphan-subscriptionA subscription whose teardown was discarded. Nothing can stop it afterwards: it survives unmount, and every route change adds another.Keep the returned function and call it on unmount, or subscribe through useTick, which unsubscribes itself when the component goes.
canvas-prop-overrideA <Canvas> given the adapter's props and then handed the same setting directly. React Three Fiber reconciles from props on every render, so the later prop wins and the adapter's decision is discarded, with no error and no type complaint.Delete the duplicate prop and let the spread supply it. Quality levels belong in the profiles argument to useRenderQuality.

Warnings

RuleWhat it catchesThe fix
orphan-rafA private requestAnimationFrame loop. Independent loops each schedule their own frame and cannot yield under load, which is the coordination the runtime exists to provide.Subscribe to the shared conductor with useTick instead.
missing-cleanupA listener, observer or timeline created and never torn down. Repeated mounts stack copies that compound into jank.Return a cleanup from the effect. For pointer, scroll and viewport, read them from useSensorBus, which attaches one set of listeners for the whole page and removes them itself.
setstate-per-frameReact state written on every frame. At 60–120 writes a second this is the single most common cause of animation jank.Write to a ref or straight to the DOM from useTick, which runs outside React. If the render genuinely needs the value, throttle it to a few times a second.
lane-disciplineA measurement taken in the write lane, which forces the browser to recalculate layout mid-frame, or a DOM write in the read lane, which dirties layout underneath every reader still measuring.Read in input, compute in update, write in render: the three lanes useTick takes as its first argument. Pass values between lanes through a ref.
transform-conflictTwo owners animating one element's transform, overwriting each other frame to frame.Give the hook its own element and move the other transform to a wrapper or child.
no-reduced-motionAutonomous motion with no prefers-reduced-motion guard, including a three.js scene drawn from its own loop. People who set that preference, often for vestibular reasons, still get the full motion.Gate it behind the preference, or read useAdaptiveQuality, which folds the preference into the quality signal. A three.js scene gets this from useThreeScene.
canvas-partial-spreaduseRenderQuality called but its result picked apart instead of spread. It returns four things, so choosing fields means choosing which of its four jobs still happens.Spread the whole object: <Canvas key={scene.generation} {...canvas}>.
webgl-context-lossNothing in the file notices a lost graphics context, whether it comes from an R3F canvas, getContext("webgl") or a hand-built new THREE.WebGLRenderer(). The canvas goes black permanently and nothing is logged, so it reads as a rendering bug rather than a recoverable event. Also raised when a file reports the loss but never calls reportHealthy().Spread useRenderQuality's props and key the canvas on a scene gate's generation. For plain three.js, use useThreeScene. For a hand-made context, attach a webglcontextlost listener that calls preventDefault() and reports with getRendererHealth().reportLost({ builtAt }), then call reportHealthy() once the replacement draws.
webgpu-device-lossA WebGPU device taken and never watched. A WebGPU canvas never fires webglcontextlost, so a listener written for WebGL will not hear this one.Pass the device to watchGPUDevice, and key the scene on generation. Through React Three Fiber, spreading useRenderQuality's props does both.
webgpu-destroy-not-a-lossA device.lost handler that treats every resolution as a failure. It also resolves when your own code calls destroy(), so unmounting reports a loss, which rebuilds the page, which unmounts more scenes.Return early when info?.reason === "destroyed", or use watchGPUDevice, which does it and coalesces simultaneous losses into one rebuild.
offscreen-renderA hand-built three.js scene drawn from its own loop with nothing to stop it off screen. Nobody sees those frames, and they are taken from whatever is on screen.Draw on the shared conductor and skip frames while the scene is out of view, or let useThreeScene do it.
uncapped-pixel-ratiosetPixelRatio(window.devicePixelRatio) with no cap. A 3x phone is asked for nine times the pixels of a 1x screen, and the ratio never comes down when the GPU cannot keep up.Cap it, Math.min(devicePixelRatio, 2), and lower it when frames slip. useThreeScene does both, and useRenderQuality never goes above the screen's own ratio.
unreleased-contextA hand-built renderer that is disposed but never gives its context back. dispose() frees three's resources, not the context. Browsers cap live contexts per page, and the oldest, usually the hero, is the first to go.Remove the loss listener, then call renderer.forceContextLoss() after renderer.dispose(). useThreeScene does this when the scene unmounts.
renderer-rebuilt-on-changeAn effect that builds a renderer and runs again whenever a value such as the quality changes. Every change throws the context away and makes a new one: one page went from 3 contexts to 7 in 20 seconds.Build the renderer once and apply changes to it with setPixelRatio and setSize. useThreeScene builds once and hands the live quality to update.

Info

RuleWhat it catchesThe fix
hand-rolled-scene-gateAn IntersectionObserver flipping React state to gate a scene. Viewport proximity alone will mount a heavy scene mid-flick on a saturated thread, at full quality, on hardware that cannot hold it.useSceneGate, which answers visibility, headroom, quality and context recovery together.
adapter-props-to-wrapperuseRenderQuality's props spread onto a component that is not <Canvas>. A wrapper absorbs them into its rest parameter and forwards them wherever it likes.Open the wrapper and confirm it spreads what it receives onto its <Canvas>, onCreated included. Otherwise spread onto the <Canvas> directly.
webgpu-renderer-class-checkinstanceof WebGPURenderer used to pick a code path. three catches a failed WebGPU init and swaps in a WebGL backend while keeping the same object, so the class can say WebGPU on a canvas that is really WebGL.Ask the backend: renderer.backend?.isWebGPUBackend. Better still, wire both paths — the WebGL listener is inert on a real WebGPU canvas and is what saves the fallback.

Three that are easy to miss

Most of the list is what you would expect a motion linter to check. These three exist because they cost real time before anyone worked out what had happened.

A spread is not evidence the props arrived

useRenderQuality returns four things, and the one that matters most is an onCreated that attaches the context-loss listener. Spread onto a wrapper component rather than onto <Canvas>, all four land in that component's rest parameter and go wherever it sends them, usually to a mesh.

A page of sixteen cards did exactly this. Every one of the adapter's jobs stopped silently. Sixteen contexts died, nothing reported them, and the health readout said generation 0 beside a grid of black cards. The spread is type-valid the whole way, because rest parameters accept anything.

adapter-props-to-wrapper is advisory rather than a warning, because a wrapper that forwards properly is a good pattern and a single file cannot see inside it. It asks you to check the canvas, not the call site.

A device you destroyed is not a device that failed

GPUDeviceLostInfo.reason is "destroyed" when your own code called device.destroy(), and unknown when the hardware actually went. Reporting the first as a failure rebuilds the scene that is being torn down, which tears down more devices, which report more losses.

This runtime shipped that shape on the WebGL side and reached generation 18 inside one second with nobody touching the page. webgpu-destroy-not-a-loss catches the hand-written version.

The lane matters as much as the priority

A layout read in the render lane runs after other subscribers have written, so it forces a synchronous recalculation every frame. On one page this cost about 46ms per frame while the runtime's own work was 2.4ms: the measurement was nearly twenty times more expensive than everything being measured.

lane-discipline is the rule with the highest hit rate on real code, and it never produces a build error.

What it cannot see

Worth stating plainly, because the gap is real.

check_motion reads one file at a time and knows the runtime's contracts. It does not know how a component in your codebase forwards its props. It can tell you the adapter's props went to a wrapper; it cannot tell you whether that wrapper passes them on. That is why adapter-props-to-wrapper asks a question instead of making a claim.

It also cannot see a wrapper that takes the props individually and drops one. Nothing in a single file makes that visible. What catches it in practice is the runtime's own reporting: a generation counter that never moves next to a canvas that has stopped drawing.