TL;DR

@shadcn/lint is an “agent-first” linter for Tailwind v4 design systems from shadcn, the creator of shadcn/ui. The pitch is simple: className in Tailwind is just a string, so a coding agent can slap p-4 bg-pink-500 rounded-full on your <Button> and nothing stops it. @shadcn/lint turns that into a lint error — and, crucially, the error message tells the agent what to use instead, pulled from your actual components, variants, and theme. Released 2026-09-14, it hit 2,154 GitHub stars in five days (38 forks, MIT), ships as a plugin for both ESLint 9.30+ and Oxlint 1.80+, and works with any Tailwind v4 project — shadcn/ui is not required.

Key facts:

  • 2,154 stars, 38 forks, 14 open issues as of 2026-09-19; repo created 2026-09-02, public launch 2026-09-14, 0.1.1 on npm since 2026-09-17
  • Six rules: no-restyle, no-raw-colors, no-arbitrary-values, no-inline-styles, no-unknown-classes, require-static-classes
  • Error messages are generated from your code — a spacing violation on Button lists the sizes Button actually defines and the file they live in
  • Evals across 150+ agent task runs: Sonnet 5, Haiku 4.5, Opus 5, and two GPT 5.6 variants all went from 42–117 violations to 0 — almost always in one correction round, at 10–48% lower correction cost than pasting the rules into the prompt
  • Already being adopted: Ultracite 7.12 shipped an opt-in preset; OpenHands opened a trial-adoption issue two days after launch
  • Gaps: JSX/TSX (and Astro) only — Svelte and Vue files pass silently; no Biome support; no per-line suppression comment yet; the Oxlint JS plugin API is still alpha

Install: npm install -D @shadcn/lint oxlint, add one rule to .oxlintrc.json, and put “run npm run lint and fix all errors” in your AGENTS.md.

The problem: Tailwind is a string, and agents love strings

Every design system has a rule like “Button owns its padding.” A human on the team learns it once. An agent learns it never — it reads the task, sees a component that renders slightly small, and writes className="p-4". That’s not a model failure; the eval doc is blunt about it: “The models are not bad at styling. They give you exactly what you asked for, and what you asked for is not in the system.”

The traditional fix is TypeScript. Restrict style to Pick<React.CSSProperties, "margin" | "width"> and you get:

TS2353: Object literal may only specify known properties, and 'padding' does not exist in type 'Pick<CSSProperties, "margin" | "width">'.

That tells the agent no. It doesn’t tell the agent how to size the button. And it does nothing for className, which is where all the Tailwind actually lives. The same override through the linter produces:

"p-4" is not allowed on <Button>: <Button> owns its spacing.
Use a size (sm, lg), or margin here or gap on the parent for space around it.
Add a size in components/ui/button.tsx only if the design explicitly calls for one.

Same rule, three extra sentences — and those sentences are what let a model fix the violation in a single pass instead of guessing. The sizes sm, lg and the file path components/ui/button.tsx are read from your source; you didn’t write them into the config.

This is the whole thesis of the project. A linter is not a new idea. A linter whose diagnostics are designed as instructions for the next model turn is.

Setup in five minutes

Requires Node 20.19+. The README’s own recommendation is to hand the setup to your agent:

Read https://github.com/shadcn-ui/lint/blob/main/SETUP.md
and set up @shadcn/lint in this project.

If you’d rather do it by hand, pick a host linter.

Oxlint (fastest path)

npm install -D @shadcn/lint oxlint
// .oxlintrc.json
{
  "jsPlugins": ["@shadcn/lint"],
  "rules": {
    "shadcn/no-restyle": ["error", { "allow": ["layout"] }]
  }
}
npx oxlint

Oxlint’s JS plugin API is what makes this work and it is explicitly alpha (Oxlint 1.80+). If you hit a strange crash, that layer is the first suspect.

ESLint

npm install -D @shadcn/lint eslint @typescript-eslint/parser
// eslint.config.mjs
import { plugin as shadcn } from "@shadcn/lint"
import tsParser from "@typescript-eslint/parser"
import { defineConfig } from "eslint/config"

export default defineConfig([
  {
    files: ["**/*.{js,jsx,ts,tsx}"],
    languageOptions: {
      parser: tsParser,
      parserOptions: { ecmaFeatures: { jsx: true } },
    },
    plugins: { shadcn },
    rules: {
      "shadcn/no-restyle": ["error", { allow: ["layout"] }],
    },
  },
])

Either way, the last step is the one that actually changes agent behaviour. Wire your choice into package.json as the lint script, then add this line to AGENTS.md (or CLAUDE.md):

After making changes, run `npm run lint` and fix all errors.

Without that line, the linter is a CI gate. With it, it’s a feedback loop inside the agent’s own turn.

shadcn/ui projects get component and theme discovery from components.json automatically; everyone else sets settings.shadcn.ui to their component prefix (e.g. "@/ds").

The six rules

RuleCatchesExample violation
no-restyleRestyling a component via className<Button className="p-4 rounded-full">
no-raw-colorsPalette colors outside your themebg-pink-500, text-[#FF6B35]
no-arbitrary-valuesBracket valuesp-[13px], w-[347px]
no-inline-stylesstyle={{}} and <style> elements<div style={{ padding: 16 }}>
no-unknown-classesClasses Tailwind can’t generaterounded-huge, text-prmary
require-static-classesClasses the linter can’t read`bg-${color}`

no-restyle is the flagship and the only one you need to start with. The others are guardrails against the ways an agent tries to route around it: if it can’t add p-4, the next move is style={{ padding: 16 }} (blocked by no-inline-styles) or p-[13px] (blocked by no-arbitrary-values). require-static-classes exists because a template literal is invisible to static analysis, and an agent will happily discover that.

Per the evals, across 150+ runs no model ever reached for an eslint-disable comment, an inline style, or a <style> element to get to green — the exact escape hatches I’d have bet on.

Contracts: per-component policies without touching component code

no-restyle with allow: ["layout"] is the sane global default — margin, width, flex, grid pass; padding, colors, typography, shape are reported. But real design systems are not uniform. Card titles might be allowed to change size; card bodies might be allowed to change padding but never font. Contracts express that:

"shadcn/no-restyle": ["error", {
  allow: ["layout"],
  contracts: [
    {
      pattern: "^CardTitle$",
      allow: ["layout", "typography"],
      deny: ["font-*"],
    },
    { pattern: "^CardContent$", allow: ["layout", "spacing"] },
    { pattern: "^Button$", allow: ["w-full", "mt-*", "mb-*"] },
  ],
}]
// Allowed
<CardTitle className="text-lg" />
<CardContent className="p-6" />
<Button size="lg" className="mt-4 md:w-full" />

// Reported
<CardTitle className="md:font-bold" />      // font-* denied on titles
<CardContent className="text-lg" />         // typography not allowed on content
<Button className="p-4 hover:rounded-full" /> // Button owns padding and shape
<Button className="md:h-12 w-48" />         // fixed height/width not in the allow list

Contracts replace the keys they write and inherit the rest; if several match, the last one wins. The important architectural point: none of this changes the component’s API. The same Button can ship with strict rules in one app and loose rules in another, and you can apply contracts to components from a third-party package you don’t own — no forks, no wrapper components. That’s the argument for a linter over a type-level solution, and it’s a good one.

Combine no-restyle with no-arbitrary-values and you get “you may change padding, but only to values on the theme scale”:

<CardContent className="p-6 md:p-8" />     // ok: theme spacing
<CardContent className="md:p-[13px]" />    // error: arbitrary value

Custom messages with placeholders

If the generated diagnostics aren’t opinionated enough, write your own. Placeholders resolve against the component that was violated:

"shadcn/no-restyle": ["error", {
  allow: ["layout"],
  message: {
    spacing: "Use a {{component}} size: {{sizes|none defined}}.",
    default: "Use a {{component}} variant: {{variants|none defined}}.",
  },
}]

For a Button that defines sm and lg, the agent sees Use a Button size: sm, lg. no-raw-colors gets {{file}}, so "Use a theme color from {{file}}." resolves to your actual src/index.css. And settings.shadcn.note appends a sentence to every diagnostic — the README example is "See DESIGN.md for design rules and approved exceptions." — which is a cheap way to keep pointing the agent back at your design doc.

The evals: what actually happened across 150+ runs

This is the part of the README that earns the “agent-first” label, because they measured it instead of asserting it. Every number ties to a dated run ID listed in docs/evals.md.

Setup: each task runs as a pair of fresh, context-free agents in an isolated copy of a small shadcn project. Before: generate the component, no linter. After: a new agent starts from that exact output and gets the task, rule descriptions, and linter diagnostics for up to three rounds. Control (“rules only”): same starting output, same rules text, same three rounds, no diagnostics — the agent is asked to review its own file.

Eight “temptation” prompts ask for off-system styling (a pink button, 13px padding, “make it pop”); a neutral suite asks for a settings page or invoices table with no styling language.

ModelTasks completedErrors beforeErrors after
Sonnet 58/8690
Haiku 4.58/8660
Opus 58/8420
GPT 5.6 Terra8/81170
GPT 5.6 Sol6/8980

Three findings worth pulling out:

  1. Everyone drifts without it — including on neutral prompts. Even with no styling language in the task, agents restyled the components they were handed on most tasks. Opus was the cleanest at ~40 findings per run; GPT 5.6 Terra the messiest at 117.
  2. Diagnostics matter most for weaker models. With rules text alone, Opus fixed everything, Sonnet needed an extra round plus two timeouts, and Haiku left one task broken after three tries. With diagnostics, all three converged in one round, at 10–48% lower correction cost. The linter’s value isn’t that a frontier model couldn’t get there — it’s that the outcome becomes automatic, verified, and cheap.
  3. The look survived. A judge model (three passes, median) scored nearly every before/after pair 9 or 10 out of 10 for intent preservation. No run “reached green by styling less.” The canonical fix path was the one you’d want: bg-[#FF6B35] text-white became a declared brand token plus a brand variant on Button, and the call site became <Button variant="brand">.

Cost for the whole eight-task run (generation + correction): Sonnet 5 $3.59–$5.17, Haiku 4.5 ~$1.70, Opus 5 $5.67–$8.44. Two Haiku misses traced to three linter gaps — a v3 gradient class misread as an undeclared color, a raw color passed through a lookup table, and a <style jsx> block no rule could see — all fixed the same day with tests.

The honest caveat, which the doc itself makes: “These are measurements of specific runs, not promises for every model or project.” The task project was small and shadcn-shaped. Your 400-file monorepo with a custom cn() wrapper will not behave identically.

What the community is saying

Five days is not long, but the signal is consistent.

  • Adoption moved fast. Ultracite 7.12.0 shipped an opt-in ultracite/oxlint/shadcn preset enabling all six rules a day after launch. OpenHands opened issue #17480 to trial it in their own UI within 48 hours.
  • The Biome question dominates the tracker. Issues #2, #14, #21, and #23 all ask the same thing. Biome’s plugin system is GritQL-only — no JS runtime, no way to pass allow/deny config — so a native port isn’t straightforward. One Biome-only monorepo reports installing Oxlint purely to host the plugin, “a second config to keep in sync, a second CI step, and two definitions of what linting means.” shadcn’s reply on #2: “I’m working on a CLI. Will consider adding this.”
  • Svelte and Vue users found a silent failure mode. In #12, someone ran the rules over 231 .svelte files with svelte-eslint-parser and got zero problems, exit 0, while the identical markup in .tsx produced four errors. cva()/tv() calls inside <script> are checked (that visitor is framework-agnostic), but Svelte’s class: and style: directives aren’t seen at all. This is a real trap: a green run means nothing if the linter never looked.
  • Existing codebases want a suppression comment. #19 describes a team whose design system already allows documented one-off overrides via a comment; 70 of the 102 no-restyle findings on their tree were on already-approved call sites. There’s no per-site exemption mechanism today, so on a brownfield codebase the genuinely new violations get buried.
  • The bug-fix velocity is high. Between 2026-09-14 and 09-17 the tracker shows a dozen merged fixes: Astro class:list support, Base UI render-prop attribution, @utility classes misreported as misspellings, non-color theme tokens (--shadow-*, --text-*) misflagged by no-raw-colors, pnpm-linked theme resolution. Normal shape for a 0.1 release meeting real projects.

Honest limitations

  • JSX/TSX only (plus Astro class:list since 0.1.1). Svelte, Vue, and any other template language get zero coverage and — worse — zero warning about it. If your stack isn’t React-shaped, this tool doesn’t apply to you yet, and there’s no adapter API to fix that yourself.
  • No Biome. If Biome is your only linter, adopting this means adding Oxlint or ESLint alongside it. The maintainer has hinted at a standalone CLI, but nothing has shipped.
  • No suppression comment. ESLint’s generic eslint-disable-next-line works at the host-linter level, but there’s no design-system-aware “approved override” mechanism, which makes incremental adoption on a large existing codebase painful.
  • Oxlint’s JS plugin host is alpha. The fast path depends on an API that Oxlint itself labels unstable.
  • Theme resolution has edge cases. Open issues cover package exports subpath patterns for CSS (./*.css), Tailwind v4 namespaced color keys, @source globs, and CSS comments containing @import "tailwindcss". When theme resolution fails, no-unknown-classes “silently degrades” — another case where a clean run may not mean what you think.
  • Five days old, Tailwind-only by design. 0.1.1, fourteen open issues, and nothing for CSS Modules, vanilla-extract, StyleX, or Panda. Evaluate accordingly.

How it compares

@shadcn/linteslint-plugin-tailwindcssTypeScript prop restrictionRules in AGENTS.md only
Blocks className overrides on components✅ per-component contracts❌ (class ordering, contradictions, unknown classes)❌ (className is a string)❌ (advisory)
Error suggests the fix from your variants/themen/a
Works on third-party componentsn/a❌ needs wrappersn/a
Verified in agent evals✅ 150+ runs❌ (the control condition)
Host lintersESLint 9.30+, Oxlint 1.80+ESLinttscnone
Framework coverageJSX/TSX, AstroJSX + Vue + moreany TSany

The closest prior art is shadcn/improve, shadcn’s earlier Agent Skill — same author, same philosophy of making the agent’s job verifiable rather than trusting it. improve writes plans that end in an exact command and expected output; lint writes diagnostics that end in an exact fix. Together they’re a coherent position on how to run coding agents: give them a deterministic check and let them iterate against it.

FAQ

Do I need shadcn/ui to use @shadcn/lint?

No. It works with any Tailwind v4 project. shadcn/ui projects get automatic component and theme discovery via components.json; everyone else sets settings.shadcn.ui to their component import prefix (e.g. "@/ds") and optionally componentImports regexes for additional packages.

Does it work with Tailwind v3?

It targets Tailwind v4 and reads the v4 CSS theme (@theme) to build its list of known classes and colors. A 0.1.1 fix added recognition of Tailwind 3 utility names so they aren’t misclassified, but the project is built around v4 and the README says so.

Which is better to host it, ESLint or Oxlint?

Oxlint is faster and the README leads with it, but its JS plugin API is alpha (Oxlint 1.80+). ESLint 9.30+ with @typescript-eslint/parser is the stable path. If your framework already ships an ESLint config, keep its parser and add the plugin, rule, and a component-directory override that turns no-restyle off inside components/ui/**.

Does it support Biome, Svelte, or Vue?

Not today. Biome has no JS plugin runtime (GritQL only), so there’s no native port; the maintainer has mentioned working on a CLI. Svelte and Vue templates are not parsed — and rather than erroring, the rules simply report nothing, so don’t mistake a green run on .svelte files for coverage. Issue #12 tracks the adapter request.

How does it stop agents from just cheating around the error?

By layering rules. no-restyle blocks the class; no-inline-styles blocks the style={{}} escape; no-arbitrary-values blocks p-[13px]; require-static-classes blocks template-literal classes the linter can’t read. In the published evals, no model used an eslint-disable, inline style, or <style> element to reach zero across 150+ runs.

Bottom line

Most “AI-ready” developer tools are the same tool with a .md file bolted on. @shadcn/lint is different: the diagnostics were designed as the next prompt, the eval harness measures whether an agent converges, and the numbers say it does — every model tested, to zero, usually in one round, cheaper than pasting rules into context.

If you run coding agents against a React + Tailwind v4 codebase with a design system worth protecting, install it today, start with no-restyle + allow: ["layout"], and put the lint command in your AGENTS.md. If you’re on Svelte, Vue, or Biome, star it and watch #12 and #14 — the idea is right, the coverage isn’t there yet.

Install: npm install -D @shadcn/lint oxlint. Repo: github.com/shadcn-ui/lint.

Sources