An ESLint plugin for Expo and Uniwind · Reads your theme, components and variants · Knows what a class does on a device
panelwind brings the approach of @shadcn/lint to React Native and Expo, and adds what only a device can tell you.
Documentation · Get started · Rules · What React Native drops · Adoption
panelwind checks how a React Native app uses its design system. It reads the project's stylesheet, its components and their variants, and reports styling that breaks the system — with an error that names the fix in terms of what the project already has.
It works with Expo projects styled with Uniwind
(Tailwind CSS v4). A project set up with
PanelUI needs no configuration at all: its
panelui.json already says where the theme and the components are.
"bg-destructive" is not allowed on <Button>: <Button> owns its color.
Use a variant: primary, secondary, destructive. Add a new variant in
src/components/ui/button.tsx only if the design calls for a treatment none of
them provides.
That message is the whole point. The variants in it were read out of your component a moment ago, so whoever is reading — a developer or an agent — is told what exists rather than that something is wrong.
Tailwind generates CSS for every valid class. React Native applies a style object to one view, and a lot of CSS has no way to become one. Those classes produce no error, no warning, and no style:
<View className="space-x-2 backdrop-blur-sm hover:bg-primary">"space-x-2" does nothing on a device: it is written as a rule about the elements
inside, and a native style applies to one view. Put the spacing on the children,
or use `gap-*` on this view.
"backdrop-blur-sm" does nothing on a device: React Native has no backdrop-filter.
"hover:bg-primary" does nothing on a device: a touch screen has no hover state.
Use `active:` for what happens under a finger.
This is answered by compiling the class with your Tailwind and your
stylesheet, then checking what it generated against the properties a React Native
style can actually carry — a table generated from React Native's own type
definitions, not written from memory. That matters more than it sounds:
cursor-pointer and select-none are not reported, because both properties
are real in React Native, and a hand-written table would have got them wrong.
Needs Node 20.19+ and ESLint 9.30+.
npm install -D panelwind eslinteslint.config.mjs:
import panelwind from 'panelwind';
export default [
...panelwind.configs.recommended,
{
// A component styles its own parts; that is not restyling.
files: ['src/components/ui/**'],
rules: { 'panelwind/no-restyle': 'off' },
},
];npx eslint .The preset configures the parser, so it works on a .tsx project with nothing
else set up. Then add the command to the file your agent reads:
After making changes, run `npm run lint` and fix everything it reports.Adding this to an app that already exists? Read adoption first — the recommended preset errors on the three findings the compiler can prove and warns on the four that are policy, which is the order you want to fix them in.
| Rule | What it reports | In recommended |
|---|---|---|
no-restyle |
A class on a component its contract does not allow. | warn |
no-raw-colors |
A colour that is not a theme token. | warn |
no-arbitrary-values |
A size that is not on the scale. | warn |
no-inline-styles |
A static style object a class could express. | warn |
require-static-classes |
A class value the bundler never sees. | error |
no-unknown-classes |
A class that generates nothing. | error |
no-web-only-classes |
A class that generates CSS a device cannot apply. | error |
configs.strict is the same set with everything at error.
A policy is written in categories — layout, color, typography, spacing,
shape, effects, motion — and contracts attach one to the components a
pattern matches.
'panelwind/no-restyle': ['error', {
allow: ['layout'],
contracts: [
// A button is placed by the screen, and looks after the rest itself.
{ pattern: '^Button$', allow: ['layout', 'w-full', 'mt-*'] },
// A card's title may change size, but not its font.
{ pattern: '^CardTitle$', allow: ['layout', 'typography'], deny: ['font-*'] },
],
}]<Button size="lg" className="mt-4 w-full" /> // allowed
<Button className="p-4 rounded-full" /> // reported: it owns its spacing and shape
<CardTitle className="text-lg" /> // allowed
<CardTitle className="font-bold" /> // reported: the contract denies font-*None of this changes your components. The same component ships with different rules in different projects.
The message is what gets acted on, so your instruction belongs in it — with the component's real variants and sizes filled in:
'panelwind/no-restyle': ['error', {
allow: ['layout'],
message: {
spacing: 'Use a {{component}} size: {{sizes}}. Never padding.',
},
}]Use a Button size: sm, md, lg. Never padding.
A standing instruction for every rule goes in settings, where it is appended to every diagnostic:
settings: {
panelwind: {
ui: '@acme/ui',
note: 'See DESIGN.md for the exceptions we have agreed.',
},
}See shared options for every placeholder and setting.
| It reads | From |
|---|---|
| The CSS entry and the theme | panelui.json, or the stylesheet that imports Tailwind |
| Your components | The component directory and design-system packages, through imports |
| Their variants | tv() and cva() in the file that defines the component, slots included |
| Your tokens | @theme for the names, the light theme for the values |
| What a class does | Your Tailwind, compiling your stylesheet |
Nothing is executed, nothing is rendered, and no simulator is involved. See how it works.