rule 02 — tier 2

Layout elements are yours — until their classes describe appearance

div, span, section, ul, li and friends are fine and necessary. Keep their classes to layout and spacing; the moment you add bg-*, border-*, rounded-*, shadow-* or text sizing/colour you are rebuilding a component that already exists.

warnlintno-appearance-classes-on-layout-elements

element usage

Layout is yours. Appearance is the library's.

what this catches

An agent rebuilds a Card in each file it touches — a div with a radius, a border and a surface tint — because writing four classes is faster than discovering that `Card` exists. The design system then drifts one file at a time, and no single diff looks wrong.

why

Layout is the consumer's job. Forcing every flex row through a <Stack> or a <Container> is a known failure mode: it buys nothing, it makes the markup harder to read, and it produces components whose only content is a className passthrough. A plain <div className="flex items-center gap-3"> is correct code and this rule leaves it alone.

Appearance is the library's job. A div that carries bg-*, border-*, rounded-*, shadow-* or text sizing/colour is not laying anything out — it is drawing a surface. Every such div is a Card, Badge, Note, or Panel that was reinvented instead of imported, and it will not follow the next token or theme change. That is the actual signal worth catching, and it is checkable from the class list alone: no scope analysis, no type information.

The gate is class *content*, not class *presence*. "Any className on a div is suspicious" is the heuristic that pushes people into wrapper-component soup; "these prefixes on a div are suspicious" is the one that finds real duplication.

the class test

allowed

  • flex
  • grid
  • block
  • hidden
  • gap-*
  • p-*
  • m-*
  • space-*
  • w-*
  • h-*
  • min-*
  • max-*
  • items-*
  • justify-*
  • col-*
  • row-*
  • order-*
  • shrink-*
  • grow-*
  • absolute
  • relative
  • sticky
  • inset-*
  • z-*
  • overflow-*
  • text-left
  • text-center
  • text-right

violating

  • bg-*
  • border-*
  • rounded-*
  • shadow-*
  • ring-*
  • divide-*
  • text-{xs,sm,base,lg,xl,2xl,...}
  • text-{color}
  • font-{thin,light,normal,medium,semibold,bold}

text-* splits across both lists: alignment (text-left/center/right) is layout; size and colour are appearance. Responsive and state prefixes do not change the verdict — sm:rounded-none and hover:bg-quebi-surface are appearance too.

wrong / right

An appearance-styled article is a Card

Was real code in src/routes/_index.tsx (features grid), until it was fixed

don't

<article
  key={title}
  className="group relative rounded-none border border-quebi-hairline bg-transparent p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-quebi-action/30"
>
  <Icon className="h-6 w-6 text-quebi-fg" strokeWidth={1.75} />
  <h3 className="mt-2 text-xl font-medium text-quebi-fg">{title}</h3>
  <p className="mt-3 text-sm leading-relaxed text-quebi-fg-muted">{body}</p>
</article>

do

import { Card, CardDescription, CardTitle } from "@/components/card"

<Card key={title} interactive>
  <Icon data-slot="icon" className="h-6 w-6 text-quebi-fg" strokeWidth={1.75} />
  <CardTitle className="mt-2">{title}</CardTitle>
  <CardDescription className="mt-3">{body}</CardDescription>
</Card>

rounded-none + border + bg-transparent + the hover lift is Card's default variant, character for character, and `interactive` is the hover treatment. The mt-* spacing is layout and stays.

Was real code in src/routes/components._index.tsx (catalog grid), until it was fixed

don't

<Link
  to={`/components/${c.slug}`}
  className="group relative rounded-none border border-quebi-hairline bg-transparent p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-quebi-action/30"
>
  <h3 className="text-xl font-medium text-quebi-fg">{c.name}</h3>
</Link>

do

import { Card, CardTitle } from "@/components/card"

<Link to={`/components/${c.slug}`} className="group block">
  <Card interactive>
    <CardTitle>{c.name}</CardTitle>
  </Card>
</Link>

Two copies of the same surface in two routes is how a design system starts drifting. The Link keeps only its layout classes.

Was real code in src/routes/_index.tsx (hero primary CTA), until it was fixed

don't

<Link
  to="/components"
  className="inline-flex items-center gap-2 rounded-none bg-quebi-action px-6 py-3 font-medium text-quebi-on-action transition-all duration-200 hover:scale-[1.02] hover:bg-quebi-action-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-quebi-action/50"
>
  Browse components
</Link>

do

import { buttonStyles } from "@/components/button"

<Link
  to="/components"
  className={buttonStyles({ intent: "primary", size: "lg", className: "text-base" })}
>
  Browse components
</Link>

The navigation is the router's, so the element stays react-router's <Link> — but the appearance is the library's. `intent="primary" size="lg"` is what those eleven classes were approximating, plus the focus-ring offset they left out. This is the composition to reach for when a route of your own has to look like a button: LinkButton would restyle it correctly and lose client-side navigation.

Do not launder plain layout through a component

don't

<Card className="flex items-center gap-3 border-0 bg-transparent p-0">
  <Avatar src={user.avatar} />
  <Text>{user.name}</Text>
</Card>

do

<div className="flex items-center gap-3">
  <Avatar src={user.avatar} />
  <Text>{user.name}</Text>
</div>

The over-correction. If you have to strip a component's appearance to use it, you wanted a div. Layout is yours.

how to check this

Add one of these to your project and the rule holds without anyone having to remember it — including the agent writing half the JSX. The exceptions below are already applied, so a documented carve-out will not be reported.

What it will and will not catch: The check is narrower than the rule: it looks for a class list carrying both a radius and a border. Expect it to catch the rebuilt Cards and miss the subtler cases — the rest of this rule stays a review question. It reads string literals, so a class list assembled from a template literal slips past either check.

Biome — GritQL plugin

biome

Biome has no built-in rule for this one, so it ships as a GritQL plugin. Save it as ui-lib-rules/no-appearance-classes-on-layout-elements.grit and load it from an `overrides` entry — the one in the file's own header, which lists the paths the rule applies to (app/**/*.{tsx,jsx}, src/**/*.{tsx,jsx}) and the paths its documented exceptions carve back out. Not from the top-level `plugins`: a plugin listed there runs against every file you lint, and an override can add one but cannot take one away. If your project keeps its components somewhere else, edit the `includes` to match.

// Layout elements are yours — until their classes describe appearance
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/no-appearance-classes-on-layout-elements.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/no-appearance-classes-on-layout-elements
//
// This file says what the rule is, not where it applies. A plugin listed in
// the top-level `plugins` runs against every file Biome lints, so scope it
// with an `overrides` entry — Biome resolves `includes` against the project
// root, which is the one thing a $filename regex in here could not do:
//
//   {
//     "overrides": [
//       {
//         "includes": [
//           "app/**/*.{tsx,jsx}",
//           "src/**/*.{tsx,jsx}",
//           "!src/components/**",
//           "!components/ui/**"
//         ],
//         "plugins": [
//           "./ui-lib-rules/no-appearance-classes-on-layout-elements.grit"
//         ]
//       }
//     ]
//   }

language js;

or { string(), JsxString() } as $classes where {
  $classes <: r".*(?:rounded-.*\bborder\b|\bborder\b.*rounded-).*",
  register_diagnostic(
    span = $classes,
    message = "Appearance classes on a layout element mean a ui-lib component is being reinvented — import Card/Badge/Note instead. Layout and spacing classes are fine. See https://ui-lib.quebi.de/rules/no-appearance-classes-on-layout-elements",
    severity = "warn"
  )
}

ripgrep — no setup at all

ripgrep

Finds candidates for review in any repo, linter or not. Coarser than the Biome check: it reads lines, not syntax, so expect false positives and treat a clean run as weaker evidence than a clean lint run.

# no-appearance-classes-on-layout-elements — candidates for review
rg -n -g '*.{jsx,tsx}' \
  -g '!src/components/**' \
  -g '!components/ui/**' \
  "className=\"[^\"]*(rounded-[^\"]*\\bborder\\b|\\bborder\\b[^\"]*rounded-)"

Claiming an exception that is not a path

biome

One exception on this rule is a judgement call, so it cannot be a path. Biome's suppression syntax has a slot for the reason — fill it, because that note is what makes the carve-out reviewable instead of invisible. The rule is named by its plugin's file name, no-appearance-classes-on-layout-elements.grit — a suppression that names this rule quiets this rule and no other plugin on the same line, and if the file is renamed Biome reports the comment as having no effect rather than guessing. `lint/plugin` on its own is valid too and quiets every plugin rule on the node at once; name the rule instead.

// biome-ignore lint/plugin/no-appearance-classes-on-layout-elements: A one-off surface the library genuinely has no component for — The rule catches duplication, not novelty.

// Between JSX children, where a comment has to be an expression, the same
// line is written {/* biome-ignore … */}.

Enforcing more than this one rule? Take the whole config instead of collecting snippets.

exceptions

Carve-outs are part of the rule, not a way around it. Each one is already an ignore glob in the checks above, so the cases listed here need no disable comment — and a case that is not listed is one to argue for, not to silence.

The ui-lib component source itself, wherever you pasted it (components/ui/**)
Components are made of appearance-styled divs — that is what a component is. The rule is about app code reimplementing a surface the library already ships.
A one-off surface the library genuinely has no component for
The rule catches duplication, not novelty. If nothing in the catalog fits, write the div — and treat it as a signal that the library is missing a component, i.e. open an issue rather than copy the div into a second file.

scope and enforcement

Applies to
  • app/**/*.{tsx,jsx}
  • src/**/*.{tsx,jsx}
Enforced by
lint