rule 07 — tier 7

A card inside a card is a section

Never write a Card inside another Card. The inner one is separating part of the outer one from the rest, and a Heading does that — with a Separator if a line is needed. Items that share the same fields are rows in a Table or a list, not cards.

warnlintno-nested-card

element usage

Layout is yours. Appearance is the library's.

what this catches

An agent that has found `Card` reaches for it for every box: a settings card grows a card per section, a card of members grows a card per member. Each one renders correctly and is the library's own component, so nothing about it looks like a shortcut — and the page ends up with borders inside borders, three levels of padding, and no heading anywhere a reader or a screen reader could use to find their way.

why

A Card says "this is one thing, separate from what is around it". A Card inside a Card says the same thing twice, one level down, and the second time it is wrong: the inner content is not separate from the outer card, it is part of it. What the inner card was doing is dividing that one thing into parts, and a heading is what divides a thing into parts — it names the part, it is in the outline, and it costs no border.

The cost is visible and it compounds. Card draws a border, a radius, a tinted surface and 20px of padding; nesting one draws all four again inside the first set, so the content ends up 40px from the outer edge behind two lines and two tints. A third level is not unusual in agent-written settings pages. None of that is a token or a theme the library can correct, because every piece of it is the library behaving exactly as asked.

When the inner cards repeat — one per member, one per invoice — they are not sections either: they are rows. Items that share the same fields read faster down a column than across a grid of boxes, and a Table (DataTable, ServerTable) or a plain list gives them alignment, sorting and a header the boxes cannot. The card page's "What to use instead" section draws each of these side by side with the card-shaped version it replaces.

It warns rather than fails. The fix is a change of shape — the inner Card becomes a Heading and a Separator, or the repeated ones become a Table — and occasionally a nested surface really is the design, say an inset preview of something that is itself a card. That is a judgement the linter cannot make, so it asks for one instead.

wrong / right

Sections, not cards

Real code from src/registry/card.examples.tsx

don't

<Card>
  <CardHeader title="Project settings" />
  <CardContent className="flex flex-col gap-3">
    <Card>
      <CardHeader title="General" description="Name and visibility." />
    </Card>
    <Card>
      <CardHeader title="Danger zone" description="Archive or delete the project." />
    </Card>
  </CardContent>
</Card>

do

<Card>
  <CardHeader title="Project settings" />
  <CardContent className="flex flex-col gap-4">
    <div>
      <Heading level={4}>General</Heading>
      <Text>Name and visibility.</Text>
    </div>
    <Separator />
    <div>
      <Heading level={4}>Danger zone</Heading>
      <Text>Archive or delete the project.</Text>
    </div>
  </CardContent>
</Card>

The inner cards were separating one part of the outer one from the rest. A heading does that and puts the part in the outline; the separator draws the line the inner border was drawing. The outer card stays — it is still one thing, separate from the page around it.

Repeated cards are rows

don't

<Card>
  <CardHeader title="Members" />
  <CardContent className="flex flex-col gap-3">
    {members.map((m) => (
      <Card key={m.id}>
        <CardHeader title={m.name} description={m.role} />
      </Card>
    ))}
  </CardContent>
</Card>

do

<Card>
  <CardHeader title="Members" />
  <CardContent>
    <DataTable
      aria-label="Members"
      columns={columns}
      data={members}
      getRowId={(m) => m.id}
    />
  </CardContent>
</Card>

A Card inside a map inside a Card is the same nesting, written once. Every member has a name and a role, which makes them rows: the table aligns them, sorts them, and says what each column is — and if the outer card has nothing else in it, the table does not need it either.

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 lexical. It reports a <Card> written inside another <Card>'s children in the same JSX, at any depth and through a .map(); it does not see a Card rendered by a child component, or one passed in a prop. That is the blind spot and it is deliberate — the copy-paste case is the one agents write, and a component boundary is usually where a frame (a gallery, a preview) meets a specimen. The ripgrep line only finds Cards; which of them are nested is a review question.

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-nested-card.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.

// A card inside a card is a section
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/no-nested-card.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/no-nested-card
//
// 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}"
//         ],
//         "plugins": [
//           "./ui-lib-rules/no-nested-card.grit"
//         ]
//       }
//     ]
//   }

language js;

or {
  JsxElement(opening_element = JsxOpeningElement(name = $name)),
  JsxSelfClosingElement(name = $name)
} as $card where {
  $name <: r"^Card$",
  $card <: within JsxElement(
    opening_element = JsxOpeningElement(name = $outer),
    children = $kids
  ) where {
    $outer <: r"^Card$",
    $kids <: contains $card
  },
  register_diagnostic(
    span = $card,
    message = "A Card inside a Card is a section: the inner one is dividing the outer one into parts, which a Heading does — add a Separator if a line is needed. If the inner cards repeat with the same fields, they are rows in a Table or a list. See https://ui-lib.quebi.de/rules/no-nested-card",
    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-nested-card — candidates for review
rg -n -g '*.{jsx,tsx}' \
  "<Card[\\s>]"

Claiming an exception that is not a path

biome

2 exceptions on this rule are a judgement call, so they 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-nested-card.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-nested-card: A frame around a rendered specimen — a gallery, a docs page, a preview — A component gallery that draws each example inside a framed Card, and an example that is itself a Card, put one card inside another on screen without either being a section of the other: the frame belongs to the page and the specimen to the example.
// biome-ignore lint/plugin/no-nested-card: An inset preview of something that is itself a card — A settings card showing what a notification card will look like, or a theme picker drawing a sample card, nests a surface because the surface is the content.

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

A frame around a rendered specimen — a gallery, a docs page, a preview
A component gallery that draws each example inside a framed Card, and an example that is itself a Card, put one card inside another on screen without either being a section of the other: the frame belongs to the page and the specimen to the example. This site's gallery does exactly that, and the check does not fire there, because the two are written in different components and the match is lexical. Only a Card written inside a Card's children in the same JSX is reported.
An inset preview of something that is itself a card
A settings card showing what a notification card will look like, or a theme picker drawing a sample card, nests a surface because the surface is the content. Say so on the line above the inner card with `{/* biome-ignore lint/plugin/no-nested-card: <reason> */}`, whose reason names what the inner card is a picture of. Name the rule: a bare `lint/plugin` is accepted too, and it quiets every plugin rule on that element — the hardcoded colour and the hand-built surface along with the nesting.

scope and enforcement

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