{"slug": "enforce-tailwind-css-design-tokens-in-vue-with-shadcn-lint", "title": "Enforce Tailwind CSS Design Tokens in Vue with @shadcn/lint", "summary": "Shadcn released @shadcn/lint, an ESLint-based tool built for AI agents that write UI code, enforcing design-system rules such as no-raw-colors and no-restyle in Vue components. In a hands-on lab, the linter flagged a BaseButton using bg-red-500 with two findings and suggested the existing variant=\"danger\" API, listing the component's primary, secondary, ghost, and danger variants and pointing to src/components/ui/BaseButton.vue. The package turns design-system contracts into checks developers and coding agents can run and re-run after edits.", "body_md": "Tailwind is awesome.\n\nI like having the styling next to the markup. I can read a component and understand its layout without jumping between files.\n\nBut there’s a huge gap between using Tailwind and automatically verifying that code follows a design system.\n\nThis becomes especially obvious when AI agents write the UI. An agent can produce valid Tailwind while ignoring the tokens and component variants already in your project. Telling it to follow the design system gives it instructions. It also needs a way to check its work.\n\nTake this button:\n\n```\n<BaseButton class=\"bg-red-500\">Ship it</BaseButton>\n```\n\n`bg-red-500` is valid Tailwind. The app builds. But the button has its own variants and theme colors. Someone just painted over them.\n\nTailwind generates CSS for valid utilities. It doesn’t decide whether a caller should change this button’s background.\n\nYou can enforce conventions with custom lint rules and other tools. That work existed before this package. But the policy doesn’t come from Tailwind itself.\n\nThat’s the motivation behind [@shadcn/lint](https://github.com/shadcn-ui/lint#built-for-agents). Shadcn built it for AI agents that write UI. When an agent breaks a design-system rule, the lint error explains the violation, suggests an existing token or variant, and points to the relevant code. The agent can make the correction and run the check again.\n\nThat’s what I like about the idea. It turns design-system contracts into checks both developers and agents can run. Components own their appearance. Callers use their variants and the classes your policy permits.\n\nI built a small Vue todo lab to try the idea. Then I deliberately broke the styling to see what the linter caught.\n\n## Try breaking the design system\n\nThe rendered button is on the left. Its Vue source is on the right. Switch to **After** below them to see the component, source, and lint findings change together.\n\nLint example\n\n## Component override\n\nA shared component owns its appearance.\n\n### ESLint output\n\npnpm exec eslint --no-ignore examples/before/component-override.vue\n\nThese findings come from actual ESLint runs against the Vue examples. The previews render Vue with a scoped stylesheet matching the lab’s theme. The widgets display captured results; they don’t run ESLint in your browser.\n\nThe first example produces two findings. `no-raw-colors` rejects the palette color. `no-restyle` rejects changing the shared button’s color at the call site.\n\nThe repair uses the button’s existing API:\n\n```\n<BaseButton variant=\"danger\">Ship it</BaseButton>\n```\n\nThe nice thing is that the message knows which variants exist. In the lab, it lists `primary`, `secondary`, `ghost`, and `danger`. It also points to `src/components/ui/BaseButton.vue`.\n\nThat’s useful feedback for a developer. It’s also useful for a coding agent. The next action is concrete, and the agent can run the same check after its edit.\n\n## A component contract is more than a color rule\n\nThe `no-restyle` rule checks recognized design-system components. Token rules also check plain elements.\n\nThat distinction matters. Changing a paragraph to `text-danger` can fix its color violation. Changing the button to `class=\"bg-danger\"` still overrides its appearance.\n\nA semantic token doesn’t give every caller permission to use it everywhere.\n\nOn a plain paragraph, the fix is simpler. Replace the palette color with the role it serves. Here, `text-red-500` becomes `text-danger`:\n\nLint example\n\n## Raw palette color\n\nPalette colors bypass the product's semantic theme.\n\n### ESLint output\n\npnpm exec eslint --no-ignore examples/before/raw-color.vue\n\nThe text still says the same thing. Its color now comes from the theme’s danger token.\n\nWith `allow: [\"layout\"]`, a caller can position a button:\n\n```\n<BaseButton class=\"mt-4 w-full\" variant=\"danger\">\n\tDelete completed tasks\n</BaseButton>\n```\n\nThe component still controls its padding, background, and radius. If the button needs another size, that belongs in its size API.\n\nDifferent components can have different contracts. A card’s content area might allow padding changes. A title might allow typography changes:\n\n```\n// Options for shadcn/no-restyle\n{\n\tallow: [\"layout\"],\n\tcontracts: [\n\t\t{ pattern: \"^CardContent$\", allow: [\"layout\", \"spacing\"] },\n\t\t{ pattern: \"^CardTitle$\", allow: [\"layout\", \"typography\"] },\n\t],\n}\n```\n\nEach contract replaces the `allow` list, so keep `layout` when you still want it. The [no-restyle documentation](https://github.com/shadcn-ui/lint/blob/main/docs/rules/no-restyle.md) explains matching and exceptions.\n\nI like this more than a blanket ban on `class`. The policy can reflect how the component is meant to work.\n\n## What the linter reads\n\nThe package analyzes source code. It reads component imports, variants, theme declarations, and supported class expressions. For unknown classes, it asks the installed Tailwind 4 whether they generate CSS.\n\nIf Tailwind or the theme cannot load, `no-unknown-classes` warns and falls back to weaker grammar checks. Resolve that warning before trusting a clean run. The [fallback documentation](https://github.com/shadcn-ui/lint/blob/main/docs/rules/no-unknown-classes.md#without-a-resolvable-theme) explains the limits.\n\nThe loop is simple: run the checks, read the allowed alternatives, edit the source, and run the checks again.\n\nThere’s no AI service judging the design. The feedback comes from static analysis and the configured rules.\n\nThe package supports React, Vue, and Svelte. You don’t need shadcn/ui components. Your own UI directory and component APIs can provide the contract.\n\nI’m using `@shadcn/lint` 0.2.0 with Tailwind 4 here. Vue templates need ESLint with `vue-eslint-parser`. Oxlint exposes the script blocks to this plugin, but not the templates. The [Vue setup documentation](https://github.com/shadcn-ui/lint/blob/main/docs/vue.md) covers that difference.\n\nIf a project already uses Oxlint, keep it for its existing checks. Add ESLint for the Vue template policy.\n\n## Add the rules to a Vue project\n\nThis setup assumes an existing Vue project with Tailwind 4. The plugin requires Node.js 20.19 or later and supports ESLint 9.30 or later. Use a Node version supported by your installed ESLint release. Check the [ESLint prerequisites](https://eslint.org/docs/latest/use/getting-started#prerequisites).\n\n```\npnpm add -D @shadcn/lint eslint @typescript-eslint/parser vue-eslint-parser\n```\n\nMerge the following into your flat config. If `eslint-plugin-vue` already configures the Vue parser, preserve that setup and add the plugin and rules there.\n\n``` python\n// eslint.config.mjs\nimport { plugin as shadcn } from \"@shadcn/lint\"\nimport tsParser from \"@typescript-eslint/parser\"\nimport { defineConfig } from \"eslint/config\"\nimport vueParser from \"vue-eslint-parser\"\n\nexport default defineConfig([\n\t{\n\t\tfiles: [\"**/*.vue\"],\n\t\tlanguageOptions: {\n\t\t\tparser: vueParser,\n\t\t\tparserOptions: { parser: tsParser },\n\t\t},\n\t\tplugins: { shadcn },\n\t\trules: {\n\t\t\t\"shadcn/no-restyle\": [\"error\", {\n\t\t\t\tallow: [\"layout\"],\n\t\t\t\tcomponentImports: [\"^@/components/ui(?:/|$)\"],\n\t\t\t}],\n\t\t\t\"shadcn/no-raw-colors\": \"error\",\n\t\t\t\"shadcn/no-arbitrary-values\": \"error\",\n\t\t\t\"shadcn/no-unknown-classes\": \"error\",\n\t\t},\n\t},\n\t{\n\t\tfiles: [\"src/components/ui/**/*.vue\"],\n\t\trules: { \"shadcn/no-restyle\": \"off\" },\n\t},\n])\n```\n\nThe last block lets shared components define their appearance. It keeps the token rules enabled inside those components. Adjust the path to your UI directory.\n\nThe plugin discovers the theme through `components.json`, or a stylesheet that imports Tailwind when that file is absent. Check discovery when the project has multiple themes.\n\nClass helpers can also live in TypeScript files. Add another config block with `files: [\"**/*.ts\"]`, `tsParser`, the plugin, and the rules you want there. The Vue block doesn’t cover those files.\n\nFor an initial run:\n\n```\npnpm exec eslint src\n```\n\nAdd that command to the project’s lint scripts and CI. If you roll out rules as warnings, use `--max-warnings 0` when you’re ready to make warnings fail CI.\n\nI would start with a small set of shared components. Fix the findings, then expand coverage. A thousand warnings that everyone ignores won’t enforce anything.\n\n## The limits are real\n\nA clean lint result doesn’t prove that the page looks good.\n\nThe linter doesn’t inspect screenshots, measure contrast, or check whether a dialog fits a narrow screen. Browser tests and visual review still have work to do.\n\nIt also has source-analysis limits. Vue `<style>` blocks and stylesheet declarations need separate CSS checks. Imported class values and runtime expressions can fall outside what it can resolve.\n\n`require-static-classes` can report unreadable class values on recognized components. It’s an additional rule, and the four-rule config above doesn’t enable it.\n\nNew theme tokens and disabled rules also deserve review. An agent can silence a finding by adding a token for every exception. That passes a token check while making the design system worse.\n\nAnd don’t expect `--fix` to design the interface. Choosing a new variant, accepting a different radius, or changing a semantic role needs judgment.\n\n## Make the tokens worth enforcing\n\nThe linter needs a useful policy. I would start with semantic names that describe a role, such as `primary`, `danger`, `surface`, and `muted-foreground`.\n\nA call site using `bg-indigo-600` describes a color choice. A shared component using `bg-primary` describes what the color does.\n\nTry the theme controls below. Switch **Indigo** to **Teal**, then switch **Light** to **Dark**. The semantic components update together. The hardcoded examples keep their fixed colors.\n\nInteractive token demo\n\n## One semantic name, coordinated changes\n\nTheme and brand controls are local to this demo. A theme switch alone does not prove that every color pair meets contrast requirements.\n\nHardcoded color\n\n### Write release notes\n\nKeep the action and status visually connected.\n\nSemantic tokens\n\n### Write release notes\n\nKeep the action and status visually connected.\n\n```\n:root {\n  --primary: #4338ca;\n  --primary-foreground: #ffffff;\n}\n\n@theme inline {\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n}\n\nclass=\"bg-primary text-primary-foreground\"\n```\n\nThe demo uses CSS variables to show the difference. A token change reaches every component that references that token. The raw values don’t have that connection.\n\nIn Tailwind 4, the mapping can look like this:\n\n```\n/* src/styles.css */\n@import \"tailwindcss\";\n\n:root {\n\t--primary: #4338ca;\n\t--primary-foreground: #ffffff;\n\t--surface: #ffffff;\n\t--foreground: #18181b;\n}\n\n.dark {\n\t--primary: #a5b4fc;\n\t--primary-foreground: #1e1b4b;\n\t--surface: #18181b;\n\t--foreground: #fafafa;\n}\n\n@theme inline {\n\t--color-primary: var(--primary);\n\t--color-primary-foreground: var(--primary-foreground);\n\t--color-surface: var(--surface);\n\t--color-foreground: var(--foreground);\n}\n```\n\nNow `bg-primary text-primary-foreground` reads the active variables. Put `.dark` on the relevant ancestor to switch those values. Explicit `dark:` utilities need the project’s corresponding dark-mode configuration.\n\nTailwind’s [theme documentation](https://tailwindcss.com/docs/theme) explains `@theme inline`. Its [dark-mode guide](https://tailwindcss.com/docs/dark-mode) covers selector-based switching.\n\nHardcoded values belong in token definitions. That’s where the design chooses actual colors. At call sites, use the roles those definitions expose.\n\nKeep foreground and background tokens paired. When a background changes, review its text color too. Check contrast in both themes and in hover, focus, and disabled states.\n\nKeep sizes and radii intentional as well. Shared components should expose a small set of sizes. Avoid adding `--special-page-13px` just to satisfy a lint rule.\n\nThis last example replaces `rounded-[13px]` with `rounded-xl`. The visual difference is small, but the radius now uses a named step:\n\nLint example\n\n## Arbitrary value\n\nOne-off values quietly create a second spacing and shape system.\n\n### ESLint output\n\npnpm exec eslint --no-ignore examples/before/arbitrary-value.vue\n\nThat changes the radius from 13px to 12px in this theme. A nearby value isn’t automatically the right value. The linter suggests a scale step; I still need to decide whether it fits.\n\nThere’s a subtle catch with spacing. Tailwind 4 supports dynamic numeric spacing utilities. Removing square brackets doesn’t create a finite spacing scale.\n\nThe [no-arbitrary-values documentation](https://github.com/shadcn-ui/lint/blob/main/docs/rules/no-arbitrary-values.md) even shows `p-[13px]` becoming `p-3.25` with the default spacing unit. Both represent 13px.\n\nIf your policy permits only specific spacing steps, enforce that restriction explicitly. `no-arbitrary-values` alone doesn’t do it.\n\nKeep conditional classes complete and visible in source:\n\n``` js\nconst statusClasses = {\n\terror: \"bg-danger text-danger-foreground\",\n\tready: \"bg-primary text-primary-foreground\",\n} as const\n```\n\nAvoid constructing `bg-${color}-500`. Complete class strings help Tailwind detect utilities and give static analysis something concrete to inspect. See [Tailwind’s source detection rules](https://tailwindcss.com/docs/detecting-classes-in-source-files).\n\nFor a stricter project, `@theme { --color-*: initial; }` removes the default color namespace. Declare your own colors afterward. Review existing usages before doing that. Resetting `--color-*` leaves `--spacing` intact.\n\nMy rule would be simple. Variants own component appearance. Semantic tokens connect components to the theme. Consumers get the layout changes their contracts allow.\n\nStart enforcing that policy on a small area. Review exceptions and new tokens as design decisions. Then a lint failure can point to a real violation, and a passing check means something specific.", "url": "https://wpnews.pro/news/enforce-tailwind-css-design-tokens-in-vue-with-shadcn-lint", "canonical_source": "https://alexop.dev/posts/enforce-tailwind-design-system-shadcn-lint/", "published_at": "2026-09-27 00:00:00+00:00", "updated_at": "2026-09-27 08:30:23.091747+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["shadcn", "@shadcn/lint", "Vue", "Tailwind CSS", "ESLint", "BaseButton"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/enforce-tailwind-css-design-tokens-in-vue-with-shadcn-lint", "markdown": "https://wpnews.pro/news/enforce-tailwind-css-design-tokens-in-vue-with-shadcn-lint.md", "text": "https://wpnews.pro/news/enforce-tailwind-css-design-tokens-in-vue-with-shadcn-lint.txt", "jsonld": "https://wpnews.pro/news/enforce-tailwind-css-design-tokens-in-vue-with-shadcn-lint.jsonld"}}