HookStack
Back to catalogue
ValidationPostToolUse· Write|Edit

Motion rules guard

Animation rule violations flagged as you write them

Enforces the project's Motion (ex-Framer Motion) rules after every component write: no 'framer-motion' imports (use 'motion/react'), no motion.* elements under LazyMotion strict (use m.*), no manual prefers-reduced-motion queries, and no raw width/height/top/left animations. Non-blocking: reports each violation with the expected fix.

What does the Motion rules guard hook do?

Motion rules guard is a Claude Code PostToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Animation rule violations flagged as you write them.

As a PostToolUse hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Stop an agent from reintroducing motion.* after a context compaction
  • Keep every animation on the shared spring/easing language

Tags

#motion#animation#react#conventions

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/motion-rules-guard.mjs",
            "type": "command"
          }
        ],
        "matcher": "Write|Edit"
      }
    ]
  }
}

Script · .claude/hooks/motion-rules-guard.mjs

#!/usr/bin/env node
// @hookstack motion-rules-guard
// Fait respecter les règles motion du projet après écriture d'un composant
// (PostToolUse Write|Edit). Règles vérifiées (cf. DESIGN.md / CLAUDE.md) :
//   - import depuis 'framer-motion' interdit → utiliser 'motion/react'
//   - éléments motion.* interdits (LazyMotion strict) → utiliser m.*
//   - pas de media query prefers-reduced-motion manuelle (MotionConfig la gère)
//   - jamais d'animation brute de width/height/top/left (transform/opacity only)
// Non bloquant : signale les violations avec la correction attendue.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

const RULES = [
	[
		/from\s+['"]framer-motion['"]/,
		"import 'framer-motion' → use 'motion/react' (paquet `motion`)",
	],
	[
		/<motion\.\w+/,
		'<motion.*> crashes under LazyMotion strict → use <m.*> (import { m } from "motion/react")',
	],
	[
		/prefers-reduced-motion/,
		'manual prefers-reduced-motion query → remove it, MotionConfig reducedMotion="user" handles it globally',
	],
	[
		/animate=\{\{[^}]*\b(?:width|height|top|left)\s*:/s,
		"animating width/height/top/left → use transform (x, y, scale) or the `layout` prop",
	],
];

export function run(input, { readFile = readFileSync } = {}) {
	const filePath = input.tool_input?.file_path ?? "";
	if (!/\/src\/.*\.[jt]sx?$/.test(filePath)) return null;

	let content;
	try {
		content = readFile(filePath, "utf8");
	} catch {
		return null; // fichier illisible/supprimé → rien à vérifier
	}

	const violations = RULES.filter(([pattern]) => pattern.test(content)).map(
		([, fix]) => fix,
	);
	if (!violations.length) return null;

	return {
		message:
			`[motion-rules] ${filePath} violates the project motion rules:\n` +
			violations.map((v) => `  - ${v}`).join("\n") +
			'\n→ See CLAUDE.md "Motion / Animations" and src/lib/motion.ts.\n',
	};
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks