Makes every moving thing in an Expo app a declared moment: named once in a catalog with what starts it, the motion tokens it spends and what it shows when motion is reduced; built with Reanimated 4 on the UI thread from wherever it is now; never in the way of the next touch; looping only while someone can see it; celebrating within a budget, once. One checker proves the code and the catalog agree.
Rules that must hold
- Declare every moment once, in the moment catalog (
src/ui/motion/motion-moments.ts): its kind, a trigger a reviewer can find in the app, its timing as token names ({ duration, easing, delay?, stagger? },{ spring }or{ navigator: 'stack' }) and its reduced alternative; plain data with type-only imports. Why: a moment nobody declared is a library default chosen by nobody, and it never meets the reduce-motion review (moment-catalog,moment-tokens). - Every file that animates names its moment. It takes timing through
timingOf/springOf/delayOf/staggerOf(MOTION_MOMENTS.<id>)(moment-config.ts), or, when it cannot import the catalog (the shared press surface, a file rendering a native<Stack>), the moment lists it infilesand the file spends the token the moment names; nothing declared goes unused. Why: the catalog stays a true list only if code cannot move without it (moment-declared,moment-files,moment-unused). - Every moment declares what reduced motion shows, as its kind allows: feedback
instant,fadeornone; stateinstantorfade; transitionfade,instantornone; attentionstaticornone; celebrationstatic,fadeornone(nonewhen drawn with<CelebrationBurst>); functionalunchangedonly; afadeusesREDUCED_FADEorreducedEntering()in code. Why: the end state must be identical with motion off (WCAG 2.3.3), and "it is small" is not an alternative (moment-reduced). - Reduce motion reaches every animation from one root. The root layout, or a file it renders,
mounts accessibility-as-code's
<MotionConfig preference>and startswatchSystemA11y(); anything that readsuseReduceMotion()oruseScreenAnimation()renders below<MotionConfig>, never in the component that mounts it; only a functional moment's file (and the reduced fade inmoment-config.ts) passesReduceMotion.Never. Why: without the config nowithTiming,withSpringor layout animation sees the in-app setting or a live switch; context flows down only, so the mounting component reads the default'system'; without the watcheruseReduceMotion()stays false (reduce-motion-root,reduce-motion-opt-out). - What the root cannot reach reads the answer itself. Reanimated CSS animations and
transitions carry the condition on the key (
transitionProperty: isReducedMotion ? 'none' : ...; a baretransitionDurationtransitions'all'); every native<Stack>takesanimation: useScreenAnimation(); React Native'sLayoutAnimationis not used. Why: Reanimated 4.5.1 applies no reduce-motion setting to CSS, and react-native-screens 4.26.2 reads none (both verified in source) (css-motion-reduced,screen-transition-reduced,reanimated-only). - Loop only through
useMotionLoop(moment, { isFocused }). It runs while the screen is focused, the app is active and motion is not reduced, and cancels a running loop when that changes. Why: Reanimated decides reduce motion once, when an animation starts, so a running loop ignores the switch; a pushed screen keeps the one below mounted, loop and battery drain included (loop-through-hook). - Motion decorates state; it never is the state. Commit in the handler first, then animate; no
completion callback hands work back to JS outside a functional moment; nothing switches input off
while something moves. Why: an interrupted animation calls back with
finished: false, so the commit never happens, and a locked control reads as a frozen app (commit-before-motion,no-input-lock). - Every animation is interruptible. Retarget from the current value (never snap to the start
first), let the finger catch the motion, and give a spring started when a drag, pinch or rotation
ends the gesture's velocity (tap, long-press and fling events carry none). Why: Reanimated starts a new animation where the old one is and a spring keeps its
momentum; a reset snaps, and a flick without velocity feels dead (
no-reset-before-animate,gesture-velocity). - Sequence on the UI thread.
withDelayandwithSequence, neversetTimeout,setIntervalorrequestAnimationFrameto start or stop an animation. Why:withDelayskips its delay under reduced motion, while a JS timer waits it out, drifts from the frames and fires after unmount (no-timer-sequencing). - Frames stay on the UI thread. No
scheduleOnRNor React setter insideuseAnimatedStyle,useDerivedValue,useAnimatedProps,useFrameCallback, a scroll handler or a gesture'sonUpdate/onChange; cross once inonEndor auseAnimatedReaction;scheduleOnRN, never the deprecatedrunOnJS/runOnUI. Why: one React render per frame is the jank Reanimated exists to remove (no-js-per-frame,worklets-api). - Animate transform and opacity. A layout key in
useAnimatedStyle(width,top,margin...) needs// motion-layout: <reason>on the line above (an absolutely positioned, childless fill); laid-out size changes use layout animations. Why: a layout key re-runs Yoga for the node and its siblings on every frame (compositor-props). - Celebrations are bounded, played once and skippable. A celebration counts its
piecesand ends withinCELEBRATION_LIMITS(library ceilings 2000 ms and 40 pieces, computed from the tokens), plays throughuseCelebrationfrom the handler that committed the result (never auseEffector focus effect), starts at the point of action and takes no touch. Why: an unbounded flourish is a loading screen nobody asked for, and a mount-effect trigger replays it on every remount (celebration-budget,celebration-through-hook).
Owned elsewhere and only cited here: motion token values, complete springs and no-raw-duration
(design-system-structure); useReduceMotion, MotionConfig and the ban on Reanimated's
useReducedMotion (accessibility-as-code); .get() / .set() (react-components-and-hooks); when
sounds and haptics play (audio-and-haptics-feedback); canvas timelines (skia-canvas-rendering).
Workflow
- Detect.
package.jsonmust depend onreact-native-reanimated(npx expo install react-native-reanimated react-native-worklets;babel-preset-expoadds the Worklets plugin); without it check-motion prints NOT APPLICABLE. The catalog reads design-system-structure'ssrc/theme/motion.ts, and the root wiring uses accessibility-as-code'ssrc/ui/a11y/. - Set up once. Copy
templates/src/ui/motion/tosrc/ui/motion/; copytemplates/src/ui/a11y/andtemplates/src/hooks/only where the app lacks them (synced, identical canonical copies). Replace__ROOT_STACK_FILE__in the catalog with the file that renders the root<Stack>and list every other<Stack>file there. StartwatchSystemA11yin the root layout and mount<MotionConfig>around the navigator, every<Stack>takinguseScreenAnimation()in a component rendered below<MotionConfig>, as examples/root-layout.tsx, examples/root-stack.tsx and examples/app-stack.tsx show. Read references/reduce-motion-and-interruption.md. - Decide whether it should move. Read references/moment-catalog.md ("Should it move at all", kinds, tokens, choreography). If the answer is no, there is no moment.
- Declare the moment. Add the entry: kind, trigger, token names (a missing token goes into the
token file first, design-system-structure), the reduced alternative its kind allows,
piecesfor a celebration,cuewhen the commit plays a sound or haptic. Imitate examples/tasks-motion-moments.ts. Review withnode ${CLAUDE_SKILL_DIR}/scripts/check-motion.mjs . --table. - Build it with the right tool (references/reanimated-4-in-practice.md,
"Choosing the tool"): a state change follows its value
(examples/task-check.tsx); enter and exit are layout animations with
reducedEntering()(examples/task-list-row.tsx); a drag springs with the finger's velocity (examples/task-sheet.tsx); a loop usesuseMotionLoop(examples/sync-badge.tsx); a celebration usesuseCelebrationand<CelebrationBurst>from the committing handler (examples/task-check-cell.tsx); a CSS transition carries the condition on its key (examples/due-chip.tsx); a fill that must animate its width carries the marker (examples/progress-bar.tsx); a safety timer is a functional moment (examples/hold-to-delete.tsx). - Test it. Read references/testing-motion.md: fake timers,
the
<MotionConfig preference>wrapper, the end state with motion reduced, an interruption, a loop's stop conditions, a celebration's once-per-event rule. - Run the checks from the repo root and loop until each passes:
npm run typecheck,npx eslint . --max-warnings 0,npx jest --ci, thennode ${CLAUDE_SKILL_DIR}/scripts/check-motion.mjs .. EachFAILline names the file, the rule and the fix: fix the code or the catalog, never widenCELEBRATION_LIMITSpast its ceilings or mark a moment functional to escape a rule. When installed, also run design-system-structure'scheck-raw-valuesand accessibility-as-code'scheck-a11y-code(they own the token and hook rules). - Ask for the device pass (human step). A release build on a named iOS simulator and a named
Android emulator, each changed moment recorded with motion on and reduced, interrupted on
purpose, haptics felt on a real phone (reduce-motion-and-interruption.md, "Trying it on
devices"). Report the
--tableoutput, the checks that passed and any SKIP line.
Definition of done
- Every moment the change touches is in the catalog with a kind, a trigger, token timing and a
reduced alternative its kind allows; the
--tableoutput is in the report. - Animating code takes its timing through
moment-config.ts; shared primitives and<Stack>files are listed in their moment'sfiles; no placeholder is left. - The root starts
watchSystemA11yand mounts<MotionConfig>; every<Stack>usesuseScreenAnimation(); CSS animations carry the reduce-motion condition on their key. - Loops run through
useMotionLoop, celebrations throughuseCelebrationfrom the committing handler; nothing locks input; no completion callback commits outside a functional moment. - Tests prove the end state with motion reduced and an interruption;
npm run typecheck,npx eslint . --max-warnings 0andnpx jest --cipass. - The developer has been asked for the device pass (release build, both platforms, motion on and reduced).
- Expected SKIP lines only while their target is not created yet:
SKIP src/ui/a11y/motion-config.tsx [reduce-motion-root] not yet created (accessibility-as-code creates it),SKIP src/app/_layout.tsx [reduce-motion-root] not yet created (expo-project-bootstrap creates it),SKIP src/theme/motion.ts [moment-tokens] not yet created (design-system-structure creates it). -
node ${CLAUDE_SKILL_DIR}/scripts/check-motion.mjs .printsRESULT: PASS
Anti-patterns
- A duration or a spring chosen in the component. It escapes the catalog and the reduce-motion
review; declare the moment and take
timingOf(MOTION_MOMENTS.<id>). - A moment whose reduced alternative is "it is short anyway". Pick from the kind's list; the end state must be reachable with motion off.
- Reanimated's
useReducedMotion(), or a second "animations off" switch for screenshots. ReaduseReduceMotion(); screenshot runs passpreference="on"to<MotionConfig>. - A
withRepeat(..., -1)in a component. It keeps running off screen, in the background and after Reduce Motion turns on; useuseMotionLoop. - A Reanimated CSS animation or a native stack slide with no reduce-motion condition. Neither
follows the root config; put the condition on the key, use
useScreenAnimation(). setTimeout(() => sv.set(withSpring(1)), 700). UsewithDelay(delayOf(m), ...); the timer waits under reduced motion and fires after unmount.- Removing a row in
withTiming(0, cfg, () => scheduleOnRN(remove)). An interrupted fade never removes it; remove it in the handler and give the rowexiting. disabled={isAnimating}orpointerEvents: isCelebrating ? 'none' : 'auto'. Let the next touch interrupt orskip(); decorative layers take no touch at all.x.set(0); x.set(withTiming(1))on every press. An interrupted animation snaps back; animate from where it is.- A release spring without the gesture's velocity, or
scheduleOnRNinonUpdate. The flick dies, or React renders every frame; passvelocity, commit inonEnd. - Animating
heightortopbecause it was easier. Animate a transform, use a layout animation, or mark the one childless fill that must keepwidth. - Celebrating from a mount effect, or with an endless confetti loop. It replays on every
remount and never ends;
useCelebration().play(eventKey)from the handler, within the budget. - Marking a moment
functionalto useReduceMotion.Never, or raisingCELEBRATION_LIMITS. Both silence the rule the person relies on; stop and ask the developer if a rule looks wrong. - Judging feel in Expo Go, a development build or the simulator alone. Feel is checked on a release build, on both platforms, with motion on and reduced.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/moment-catalog.md | Why one catalog, every field, kinds and their reduced alternatives, timing shapes, the should-it-move gate, token roles, choreography and direction, cues, files, the celebration budget, --table, config keys, multi-package |
Workflow steps 3 and 4 |
| references/reanimated-4-in-practice.md | Verified APIs (4.5.1, Worklets 0.10.1, Gesture Handler 2.32, screens 4.26.2): setup, runtimes, choosing the tool, retargeting, callbacks, delay/sequence/repeat under reduced motion, layout and CSS animations, gestures, property costs, native stack, iOS and Android notes, troubleshooting | Workflow step 5, any Reanimated question |
| references/reduce-motion-and-interruption.md | The one answer, root wiring, what the root reaches and what it does not, each kind in code, the reduced fade, loop stop conditions, interruption, commit first, device toggles on named devices, screenshots held still | Workflow steps 2 and 8 |
| references/testing-motion.md | Jest setup, fake timers, the MotionConfig wrapper, what to assert, recipes (end state, interruption, loop, celebration, functional timer), what Jest cannot prove | Workflow step 6 |
templates/src/ui/motion/ |
The catalog template (motion-moments.ts: kinds, reduced alternatives, token types, CELEBRATION_LIMITS, press, release, screen push) and its Jest twin; moment-config.ts (timing, spring, delay, stagger, celebration plan, the reduced fade); use-motion-loop.ts; use-celebration.ts; use-screen-animation.ts; celebration-burst.tsx and burst-piece.tsx; each with its test |
Workflow step 2 (copy to src/ui/motion/) |
templates/src/ui/a11y/ |
accessibility-as-code's system store, useReduceMotion and MotionConfig with their tests (synced; never edit here) |
Workflow step 2, only when the app has none |
templates/src/hooks/ |
react-components-and-hooks' use-is-app-active.ts with its test (synced; never edit here) |
Workflow step 2, only when the app has none |
| examples/tasks-motion-moments.ts | A Tasks catalog using every kind of moment | Workflow step 4 |
| examples/root-layout.tsx | The root layout starting the system accessibility store, with the gesture root | Workflow step 2 |
| examples/root-stack.tsx | The providers above the root Stack: MotionConfig with the saved preference, reading nothing itself | Workflow step 2 |
| examples/app-stack.tsx | The root Stack rendered below MotionConfig, animations from useScreenAnimation(); listed in screenPush.files |
Workflow step 2 |
| examples/task-check.tsx | A state moment that retargets from where it is | Workflow step 5 |
| examples/task-check-cell.tsx | A celebration played from the committing handler, drawn at the point of action | Workflow step 5 |
| examples/task-list-row.tsx | Enter and exit as layout animations, reduced fade | Workflow step 5 |
| examples/task-sheet.tsx | A drag caught mid-flight, released with the finger's velocity | Workflow step 5 |
| examples/sync-badge.tsx | An attention loop through useMotionLoop |
Workflow step 5 |
| examples/due-chip.tsx | A Reanimated CSS transition gated on reduce motion | Workflow step 5 |
| examples/progress-bar.tsx | The one sanctioned layout key, with its marker | Workflow step 5 |
| examples/hold-to-delete.tsx | A functional timer that never shortens and commits on completion | Workflow step 5 |
scripts/check-motion.mjs |
Checker: the catalog, code tied to it, the reduce-motion root and what it cannot reach, loops, celebrations, interruption, UI-thread frames, compositor properties (22 rules; --table, --catalog, --root-layout, --exclude; --help lists them) |
Workflow steps 4 and 7 |
scripts/selftest.mjs |
Proves the checker on a full Tasks app built on the templates and on a fresh adoption, and every planted bug | After changing the checker, a template or a fixture |
scripts/lib/ |
motion-catalog.mjs (loads and judges the catalog), motion-code.mjs (per-file rules); synced source-scan.mjs and app-modules.mjs (never edit here) |
When changing a rule |
scripts/check-lib.mjs |
Shared script helper (synced; never edit here) | Never by hand |
assets/shared.json |
Declares the synced files (check-lib, scan and module helpers, the a11y and app-active templates, the token file of the test base) | When adding a shared file |
assets/template-placeholders.json |
The placeholder value the reference app fills in (__ROOT_STACK_FILE__) |
When adding a placeholder |
tests/ |
bases/app/ (a neutral Tasks app with every kind of moment and silent planted findings under .agents/skills/, skills/, src/canvas/ and a vendored skill folder) and fixtures/ (overlay suites check-motion and check-motion-adopt) |
When adding a rule |
Related skills
design-system-structure- the motion tokens, complete springs and the raw-duration ban this skill spends.accessibility-as-code-useReduceMotion,MotionConfigand the system store; screen-reader alternatives for holds and gestures.react-components-and-hooks-.get()/.set(), effects, and the shareduseIsAppActivehook.audio-and-haptics-feedback- the cue table a moment'scuenames, played at the commit.settings-and-preferences- the saved "Reduce motion" preference passed toMotionConfig.skia-canvas-rendering- canvas timelines and their reduced timelines.canvas-gestures-and-hit-testing- gestures on a canvas turned into commands.navigation-and-routing- the Stack layouts whose animation comes fromuseScreenAnimation().rtl-and-direction- which way a horizontal moment moves in right-to-left languages.ui-component-library- the press surface's users and the hold button built on a functional moment.performance-budgets- frame-time budgets measured on devices.debug-menu-and-test-hooks- launching test builds with motion held still.unit-and-component-tests- the Jest set-up and render helpers the motion tests use.- Expo's
expo-animationskill - a broader Reanimated and gesture API reference.