Element usage

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.

Tier 2warnlintno-appearance-classes-on-layout-elements

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-quebi-md and hover:bg-quebi-surface are appearance too.

Wrong / right

An appearance-styled article is a Card

Real code from src/routes/_index.tsx (features grid)

Don't

<article
  key={title}
  className="group relative rounded-quebi-md border border-quebi-line/10 bg-quebi-surface/[0.02] p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-quebi-brand/30 hover:shadow-quebi-glow"
>
  <Icon className="h-6 w-6 text-quebi-brand" strokeWidth={1.75} />
  <h3 className="mt-2 text-xl font-semibold 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-brand" strokeWidth={1.75} />
  <CardTitle className="mt-2">{title}</CardTitle>
  <CardDescription className="mt-3">{body}</CardDescription>
</Card>

rounded-quebi-md + border + bg-quebi-surface/[0.02] + the hover lift is Card's default variant, character for character, and `interactive` is the hover treatment. The mt-* spacing is layout and stays.

The same surface, hand-built inside a link

Real code from src/routes/components._index.tsx (catalog grid)

Don't

<Link
  to={`/components/${c.slug}`}
  className="group relative rounded-quebi-md border border-quebi-line/10 bg-quebi-surface/[0.02] p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-quebi-brand/30 hover:shadow-quebi-glow"
>
  <h3 className="text-xl font-semibold 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.

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 add that path to `plugins` in your biome.jsonc. Its documented exceptions are compiled in as `$filename` guards, because Biome's overrides do not scope plugins.

// 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

language js;

or { string(), JsxString() } as $classes where {
  $classes <: r".*(?:rounded-.*\bborder\b|\bborder\b.*rounded-).*",
  // documented exception: src/components/**
  not $filename <: r".*src/components/.*",
  // documented exception: components/ui/**
  not $filename <: r".*components/ui/.*",
  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 '*.{tsx,jsx}' \
  -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.

{/* biome-ignore plugin: A one-off surface the library genuinely has no component for — The rule catches duplication, not novelty. */}

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