Element usage

Import the library's components, not the primitives underneath them

react-aria-components is ui-lib's dependency, not yours. In app code import <Button> from @/components/button, never from react-aria-components — the primitive is unstyled and knows nothing about the quebi variants.

Tier 4errorlintimport-components-not-primitives

Layout is yours. Appearance is the library's.

What this catches

An agent trained on react-aria's own documentation imports `Button` from `react-aria-components`, because that is what the examples it learned from do. The JSX then reads as if the library were being used while none of its design is present.

Why

The element rules stop at the element name, and this walks straight past them. `import { Button } from "react-aria-components"` renders `<Button>`, which no element check objects to — and gets you a control with the accessibility but none of the design: no intents, no sizes, no focus ring in the quebi idiom, nothing that follows a token change. It is the same layering mistake as a hand-rolled `<button>`, one level up, and it is harder to spot in review because the JSX looks right.

There are exactly three layers here and each imports from the one below it. App code imports quebi components. Quebi components import react-aria primitives — that is what they are for; quebi's Button does not wrap `<button>`, it wraps react-aria's Button. React-aria renders the intrinsic. A layer reaching two levels down is the definition of a leaky abstraction, and the import is the last place it can leak.

The ban is derived, not curated: it is exactly the set of primitives the library's own source imports. Wrap a new one tomorrow and it becomes app-forbidden the same day. What stays importable is what the library does not wrap — `parseColor`, `useLocale`, `useFilter`, and every type, since types are erased and a value handed to you by a quebi component is often typed by react-aria.

Use this instead

Button (react-aria-components)
  • Button from @/components/button
TextField / Input (react-aria-components)
Link (react-aria-components)
Modal / Dialog (react-aria-components)
  • Modal from @/components/modal
  • Dialog from @/components/dialog
Anything else the library wraps
  • the quebi component of the same name from @/components/<slug>

The banned list is generated from the library's own react-aria imports, so it always matches what ui-lib actually wraps — around 114 primitives today.

Wrong / right

Reaching past the library for a primitive

Don't

import { Button } from "react-aria-components"

<Button onPress={save}>Save</Button>

Do

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

<Button intent="primary" onPress={save}>
  Save
</Button>

Both render an accessible button. Only one of them is a quebi button, and no element check can tell them apart — which is why this rule reads imports rather than JSX.

What stays importable

Don't

import { ColorSwatch, parseColor } from "react-aria-components"

Do

import { parseColor } from "react-aria-components"
import type { DateValue } from "react-aria-components"
import { ColorSwatch } from "@/components/color-swatch"

Helpers the library does not wrap (parseColor, useLocale, useFilter) and every type import are fine — types are erased, and a value a quebi component hands you is often typed by react-aria. Only the wrapped primitives are banned.

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 reads the import list, so it says nothing about what you do with the primitive — and it cannot see a re-export chain that reaches react-aria by another name. The ripgrep version finds every react-aria import including the legitimate ones, so read it as a list to review rather than a list of violations.

Biome — style/noRestrictedImports

biome

A built-in Biome rule, so there is nothing to install and no pattern to maintain. One message per element, and the documented exceptions are ordinary `overrides`.

// biome.jsonc
{
  "linter": {
    "rules": {
      "style": {
        "noRestrictedImports": {
          "level": "error",
          "options": {
            "elements": {}
          }
        }
      }
    }
  },
  "overrides": [
    {
      "includes": [
        "src/components/**",
        "components/ui/**"
      ],
      "linter": {
        "rules": {
          "style": {
            "noRestrictedImports": "off"
          }
        }
      }
    }
  ]
}

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.

# import-components-not-primitives — candidates for review
rg -n -g '*.{tsx,jsx}' \
  -g '!src/components/**' \
  -g '!components/ui/**' \
  "from \"react-aria-components\""

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 lint/style/noRestrictedImports: A primitive the library genuinely does not wrap — If react-aria ships something with no quebi equivalent, importing it directly is the only option, and the derived ban will not contain it. */}

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/**)
This is the layer that imports the primitives — it is what makes the components components. The rule governs the code above it.
A primitive the library genuinely does not wrap
If react-aria ships something with no quebi equivalent, importing it directly is the only option, and the derived ban will not contain it. When you find yourself doing that repeatedly, the answer is a new component in the library rather than a suppression in every file.

Scope and enforcement

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