/**
* Rule: prefer-tailwind
*
* Encourages using Tailwind className over inline style attributes.
* - Detects files with a high ratio of inline `style` vs `className` usage
* - Warns at each element using style without className when ratio exceeds threshold
* - When preferSemanticColors is enabled, warns against hard-coded colors
* - When useLlmSuggestions is enabled, uses Ollama to suggest semantic replacements
*/
import { readFileSync } from "fs";
import { dirname } from "path";
import { createRule, defineRuleMeta } from "../../utils/create-rule.js";
import type { TSESTree } from "@typescript-eslint/utils";
import {
getColorSuggestions,
formatSuggestionsForMessage,
} from "./lib/color-suggester.js";
type MessageIds =
| "preferTailwind"
| "preferSemanticColors"
| "preferSemanticColorsWithSuggestion"
| "preferSemanticClassGroups"
| "semanticOpacityModifier"
| "componentVariantLeakage";
type Options = [
{
/** Minimum ratio of style-only elements before warnings trigger (0-1). Default: 0.3 */
styleRatioThreshold?: number;
/** Don't warn if file has fewer than N JSX elements with styling. Default: 3 */
minElementsForAnalysis?: number;
/** Style properties to ignore (e.g., ["transform", "animation"] for dynamic values). Default: [] */
allowedStyleProperties?: string[];
/** Component names to skip (e.g., ["motion.div", "animated.View"]). Default: [] */
ignoreComponents?: string[];
/** Prefer semantic colors (bg-destructive) over hard-coded (bg-red-500). Default: true */
preferSemanticColors?: boolean;
/** Hard-coded color names to allow when preferSemanticColors is enabled. Default: [] */
allowedHardCodedColors?: string[];
/** Use LLM (Ollama) to suggest semantic color replacements. Default: false */
useLlmSuggestions?: boolean;
/** Prefer semantic component classes over dense visual utility clusters. Default: true */
preferSemanticClassGroups?: boolean;
/** Number of visual utilities on one element/class string before warning. Default: 4 */
visualUtilityThreshold?: number;
/** Minimum distinct visual utility groups before warning. Default: 2 */
visualUtilityMinGroups?: number;
/** Disallow opacity modifiers on semantic color tokens. Default: true */
disallowSemanticOpacityModifiers?: boolean;
/** Exact classes to allow even when they use semantic opacity modifiers. Default: [] */
allowedOpacityModifierClasses?: string[];
/** Exact classes to allow in visual utility cluster detection. Default: [] */
allowedVisualUtilityClasses?: string[];
/** Prefer component variants over bespoke visual className overrides. Default: true */
preferComponentVariants?: boolean;
/** Optional component names to inspect; empty means any custom JSX component. */
componentVariantComponents?: string[];
/** Prop names that indicate the component already exposes styling variants. */
componentVariantProps?: string[];
/** Number of styling override classes before warning on a variant component. Default: 4 */
componentVariantClassThreshold?: number;
/** Exact classes to allow in component variant leakage detection. Default: [] */
allowedComponentVariantClasses?: string[];
}?
];
/**
* Rule metadata - colocated with implementation for maintainability
*/
export const meta = defineRuleMeta({
id: "prefer-tailwind",
version: "1.3.0",
name: "Prefer Tailwind",
description: "Encourage Tailwind className over inline style attributes",
defaultSeverity: "warn",
category: "static",
icon: "🎨",
hint: "Prefers className over inline styles",
defaultEnabled: true,
isDirectoryBased: true,
defaultOptions: [
{
styleRatioThreshold: 0.3,
minElementsForAnalysis: 3,
allowedStyleProperties: [],
ignoreComponents: [],
preferSemanticColors: true,
allowedHardCodedColors: [],
useLlmSuggestions: false,
preferSemanticClassGroups: true,
visualUtilityThreshold: 4,
visualUtilityMinGroups: 2,
disallowSemanticOpacityModifiers: true,
allowedOpacityModifierClasses: [],
allowedVisualUtilityClasses: [],
preferComponentVariants: true,
componentVariantComponents: [],
componentVariantProps: ["variant", "size"],
componentVariantClassThreshold: 4,
allowedComponentVariantClasses: [],
},
],
optionSchema: {
fields: [
{
key: "styleRatioThreshold",
label: "Style ratio threshold",
type: "number",
defaultValue: 0.3,
description:
"Minimum ratio (0-1) of style-only elements before warnings trigger",
},
{
key: "minElementsForAnalysis",
label: "Minimum elements",
type: "number",
defaultValue: 3,
description: "Don't warn if file has fewer styled elements than this",
},
{
key: "allowedStyleProperties",
label: "Allowed style properties",
type: "text",
defaultValue: "",
description:
"Comma-separated list of style properties to allow (e.g., transform,animation)",
},
{
key: "ignoreComponents",
label: "Ignored components",
type: "text",
defaultValue: "",
description:
"Comma-separated component names to skip (e.g., motion.div,animated.View)",
},
{
key: "preferSemanticColors",
label: "Prefer semantic colors",
type: "boolean",
defaultValue: true,
description:
"Warn against hard-coded colors (bg-red-500) in favor of semantic theme colors (bg-destructive)",
},
{
key: "allowedHardCodedColors",
label: "Allowed hard-coded colors",
type: "text",
defaultValue: "",
description:
"Comma-separated color names to allow when preferSemanticColors is enabled (e.g., gray,slate)",
},
{
key: "useLlmSuggestions",
label: "Use LLM suggestions",
type: "boolean",
defaultValue: false,
description:
"When enabled, uses Ollama to suggest semantic color replacements based on your project's theme",
},
{
key: "preferSemanticClassGroups",
label: "Prefer semantic class groups",
type: "boolean",
defaultValue: true,
description:
"Warn when one element uses many low-level visual utilities that should be captured by a semantic class",
},
{
key: "visualUtilityThreshold",
label: "Visual utility threshold",
type: "number",
defaultValue: 4,
description:
"Minimum number of visual utilities in one class string before warning",
},
{
key: "visualUtilityMinGroups",
label: "Visual utility group threshold",
type: "number",
defaultValue: 2,
description:
"Minimum number of distinct visual utility groups before warning",
},
{
key: "disallowSemanticOpacityModifiers",
label: "Disallow semantic opacity modifiers",
type: "boolean",
defaultValue: true,
description:
"Warn on token opacity like text-foreground/80 or border-border/40",
},
{
key: "allowedOpacityModifierClasses",
label: "Allowed opacity modifier classes",
type: "text",
defaultValue: "",
description:
"Comma-separated exact classes to allow with semantic opacity modifiers",
},
{
key: "allowedVisualUtilityClasses",
label: "Allowed visual utility classes",
type: "text",
defaultValue: "",
description:
"Comma-separated exact visual utility classes to ignore in cluster detection",
},
{
key: "preferComponentVariants",
label: "Prefer component variants",
type: "boolean",
defaultValue: true,
description:
"Warn when design-system components with variant or size props also carry bespoke styling classes",
},
{
key: "componentVariantComponents",
label: "Component variant components",
type: "text",
defaultValue: "",
description:
"Optional comma-separated component names to inspect; empty means any custom JSX component with variant props",
},
{
key: "componentVariantProps",
label: "Component variant props",
type: "text",
defaultValue: "variant,size",
description:
"Comma-separated prop names that indicate a component already exposes styling variants",
},
{
key: "componentVariantClassThreshold",
label: "Component variant class threshold",
type: "number",
defaultValue: 4,
description:
"Minimum number of styling override classes before warning on a variant component",
},
{
key: "allowedComponentVariantClasses",
label: "Allowed component variant classes",
type: "text",
defaultValue: "",
description:
"Comma-separated exact classes to ignore when checking component variant leakage",
},
],
},
docs: `
## What it does
Detects files with a high ratio of inline \`style\` attributes versus \`className\` usage
in JSX elements. Reports warnings on elements that use \`style\` without \`className\`,
but only when the file exceeds a configurable threshold ratio.
## Why it's useful
- **Consistency**: Encourages using Tailwind's utility classes for styling
- **Maintainability**: Tailwind classes are easier to read and maintain than inline styles
- **Performance**: Tailwind generates optimized CSS; inline styles can't be deduplicated
- **Theming**: Tailwind classes work with dark mode and responsive variants
## Examples
### ❌ Incorrect (when file exceeds threshold)
\`\`\`tsx
// Many elements using style without className
Red text
Spaced
Paragraph
\`\`\`
### ✅ Correct
\`\`\`tsx
// Using Tailwind className
Red text
Spaced
Paragraph
// Both style and className (acceptable for dynamic values)
Mixed
\`\`\`
## Configuration
\`\`\`js
// eslint.config.js
"uilint/prefer-tailwind": ["warn", {
styleRatioThreshold: 0.3, // Warn when >30% of elements are style-only
minElementsForAnalysis: 3, // Need at least 3 styled elements to analyze
allowedStyleProperties: ["transform", "animation"], // Skip these properties
ignoreComponents: ["motion.div", "animated.View"], // Skip animation libraries
preferSemanticColors: true, // Warn on hard-coded colors like bg-red-500
allowedHardCodedColors: ["gray", "slate"], // Allow specific color palettes
preferSemanticClassGroups: true,
visualUtilityThreshold: 4,
visualUtilityMinGroups: 2,
disallowSemanticOpacityModifiers: true
}]
\`\`\`
## Semantic Colors
When \`preferSemanticColors\` is enabled, the rule warns against hard-coded Tailwind color classes
in favor of semantic theme colors:
### ❌ Hard-coded colors (when enabled)
\`\`\`tsx