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.1on 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
Buttonlists the sizesButtonactually 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
| Rule | Catches | Example violation |
|---|---|---|
no-restyle | Restyling a component via className | <Button className="p-4 rounded-full"> |
no-raw-colors | Palette colors outside your theme | bg-pink-500, text-[#FF6B35] |
no-arbitrary-values | Bracket values | p-[13px], w-[347px] |
no-inline-styles | style={{}} and <style> elements | <div style={{ padding: 16 }}> |
no-unknown-classes | Classes Tailwind can’t generate | rounded-huge, text-prmary |
require-static-classes | Classes 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.
| Model | Tasks completed | Errors before | Errors after |
|---|---|---|---|
| Sonnet 5 | 8/8 | 69 | 0 |
| Haiku 4.5 | 8/8 | 66 | 0 |
| Opus 5 | 8/8 | 42 | 0 |
| GPT 5.6 Terra | 8/8 | 117 | 0 |
| GPT 5.6 Sol | 6/8 | 98 | 0 |
Three findings worth pulling out:
- 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.
- 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.
- 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-whitebecame a declared brand token plus abrandvariant onButton, 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/shadcnpreset 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/denyconfig — 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
.sveltefiles withsvelte-eslint-parserand got zero problems, exit 0, while the identical markup in.tsxproduced four errors.cva()/tv()calls inside<script>are checked (that visitor is framework-agnostic), but Svelte’sclass:andstyle: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-restylefindings 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:listsupport, Base UI render-prop attribution,@utilityclasses misreported as misspellings, non-color theme tokens (--shadow-*,--text-*) misflagged byno-raw-colors, pnpm-linked theme resolution. Normal shape for a 0.1 release meeting real projects.
Honest limitations
- JSX/TSX only (plus Astro
class:listsince 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-lineworks 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
exportssubpath patterns for CSS (./*.css), Tailwind v4 namespaced color keys,@sourceglobs, 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/lint | eslint-plugin-tailwindcss | TypeScript prop restriction | Rules 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/theme | ✅ | ❌ | ❌ | n/a |
| Works on third-party components | ✅ | n/a | ❌ needs wrappers | n/a |
| Verified in agent evals | ✅ 150+ runs | ❌ | ❌ | ❌ (the control condition) |
| Host linters | ESLint 9.30+, Oxlint 1.80+ | ESLint | tsc | none |
| Framework coverage | JSX/TSX, Astro | JSX + Vue + more | any TS | any |
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
- shadcn-ui/lint on GitHub — README, rules, settings (accessed 2026-09-19)
- docs/evals.md — methodology, per-model results, run IDs
- shadcn’s launch thread on X — 2026-09-14
- Issue #12 (framework adapters), #14 (Biome), #19 (suppression comments)
- Ultracite 7.12.0 changelog — opt-in preset
- @shadcn/lint on npm — 0.1.1, peer
eslint >=9.30.0