rule 01 — tier 1

Never render interactive or semantic HTML elements directly

button, input, select, textarea, a, form, label, dialog and table are the library's. Import the component instead — this holds whether or not you style the element.

errorlintno-raw-interactive-elements

element usage

Layout is yours. Appearance is the library's.

what this catches

An agent reaches for a styled `<button>` because it is the shortest path to something that looks right on screen. What it produces looks correct in review and has lost focus management, press handling, and every ARIA connection the library's component would have brought.

why

These elements are not markup, they are behaviour. A ui-lib component wraps each one in a react-aria-components primitive that supplies focus management, press handling (pointer, keyboard, touch, and the 300ms-free press semantics), disabled and pending states, and the ARIA wiring that connects a control to its label, description, and error text. Rendering the intrinsic yourself throws all of that away and nothing in review reliably catches the absence of behaviour.

Styling is the wrong axis to judge this on. An *unstyled* <button> is worse than a styled one, not better: it still misses focus-visible rings, still fails to pick up the react-aria context its ancestors provide (a Dialog's close-on-press, a Toolbar's roving tabindex, a Form's submission state), and it silently reads as a plain button to assistive technology. The safe-looking case is the trap.

There is exactly one layer allowed to render these intrinsics, and it is the library. That is what makes a theme change, an accessibility fix, or a react-aria upgrade a one-file change instead of a codebase-wide hunt.

use this instead

<button>
  • Button from @/components/buttonperforms an action in place
  • LinkButton from @/components/link-buttonnavigates — a control that changes the URL must be an anchor
  • Toggle from @/components/togglehas an on/off pressed state

Button takes onPress, not onClick — that is the point, not an inconvenience: onPress covers pointer, keyboard, and touch uniformly.

<a>
  • Link from @/components/linkan inline text link (it renders a plain anchor for http(s)/mailto/tel hrefs, so external links keep working outside a router)
  • LinkButton from @/components/link-buttona link that should look like a button

In a React Router app, navigation between your own routes stays on the router's <Link>/<NavLink>: they render an anchor and add client-side navigation, and this rule is not asking you to give that up. Reach for ui-lib's <Link> for everything the router does not own — an external site, a static file served from /public, a mailto: — which is exactly where a raw <a> tends to survive. When one of your own routes should look like a button, keep the router's <Link> and take the appearance from `buttonStyles` (exported by @/components/button) instead of reaching for LinkButton: LinkButton is react-aria's Link, and with no react-aria RouterProvider wired to your router it navigates with a full page load. Composing the styles keeps client-side navigation and the design tokens at the same time.

<input>
  • TextField from @/components/text-fielda labelled field with description and error text
  • Input from @/components/inputthe bare control, inside a Field you compose yourself
  • SearchField from @/components/search-fieldtype=search
  • NumberField from @/components/number-fieldtype=number — adds steppers and locale-aware parsing
  • Checkbox from @/components/checkboxtype=checkbox
  • RadioGroup from @/components/radiotype=radio
  • Switch from @/components/switcha boolean rendered as a switch
<select>
  • Select from @/components/selecta single choice from a known list
  • ComboBox from @/components/combo-boxthe list is long enough to need typeahead
  • MultipleSelect from @/components/multiple-selectmultiple
<textarea>
<form>
  • Form from react-routersubmitting to a route action
  • ConformField from @/components/conform-fieldbinding fields to a Conform form

ui-lib deliberately ships no Form component: submission belongs to the router. Use React Router's Form and the conform-* variants for the fields inside it.

<label>
  • Label from @/components/fieldcomposing a field by hand

Most form components render their own label from a `label` prop or their children, and wire htmlFor/id themselves. Reach for the Label slot only when you are assembling a Field yourself.

<dialog>
  • Modal from @/components/modala centered overlay
  • Drawer from @/components/draweran edge-anchored panel
  • Sheet from @/components/sheeta side sheet
  • Dialog from @/components/dialogthe dialog content itself (title, body, footer)

The native <dialog> element has no focus-trap parity across browsers and no scroll locking. The library's overlays get both from react-aria.

<table>
  • Table from @/components/tablea static table (with TableHeader, TableBody, TableColumn, TableRow, TableCell)
  • DataTable from @/components/data-tablethe rows you have are all of them — the browser holds the dataset and sorts, filters, pages and groups it
  • ServerTable from @/components/server-tablethe rows are a page of a larger answer — sorting, filtering and paging are queries the server runs
  • TableShell from @/components/table-shellneither mode fits and you are bringing a TanStack instance of your own

Table is not a drop-in for <table>: react-aria throws at runtime unless exactly one Column carries isRowHeader, every Row has an id, and each row has one cell per column — so a hand-rolled table migrated without row ids crashes rather than degrades. DataTable and ServerTable do all three for you (getRowId is required, the row header is the first column, the cells come from the column list); bare Table does not. Between those two the question is where the rows come from, not how many there are — DataTable holds the answer and sorts it, ServerTable reports intent and asks again. They are two assemblies of the same parts: TableShell (@/components/table-shell) draws rows, and the toolbar, filter panels, pager and bulk bar come from @/components/table-controls — both usable directly when neither ready-made mode is the right shape. There is also no footer or caption wrapper on bare Table: a <tfoot> has no ui-lib equivalent, and because the primitive ban is derived from what the library imports, TableFooter is not banned — importing it is the honest migration, and the one place this rule sends you to react-aria rather than to a component.

wrong / right

A raw anchor where a LinkButton belongs

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

don't

<a
  href="https://github.com/quebi-gmbh"
  target="_blank"
  rel="noreferrer"
  className="inline-flex items-center gap-2 rounded-none border border-quebi-hairline px-6 py-3 text-quebi-fg transition-colors duration-200 hover:border-quebi-action hover:text-quebi-fg"
>
  GitHub
</a>

do

import { LinkButton } from "@/components/link-button"

<LinkButton href="https://github.com/quebi-gmbh" target="_blank" rel="noreferrer" intent="outline">
  GitHub
</LinkButton>

The hand-written classes are an approximation of buttonStyles({ intent: 'outline' }) that will not follow the next token change. The component also brings the focus ring the anchor is missing.

A styled raw button where a Button belongs

Was real code in src/routes/components.tsx (mobile sidebar toggle), until it was fixed

don't

<button
  type="button"
  onClick={() => setMobileOpen((o) => !o)}
  className="mb-4 inline-flex items-center gap-2 rounded-none border border-quebi-hairline px-3 py-2 text-sm text-quebi-fg-muted transition-colors duration-200 hover:border-quebi-action hover:text-quebi-fg lg:hidden"
  aria-expanded={mobileOpen}
>
  <Menu className="h-4 w-4" />
  Components
</button>

do

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

<Button
  intent="outline"
  size="sm"
  onPress={() => setMobileOpen((o) => !o)}
  aria-expanded={mobileOpen}
  className="mb-4 lg:hidden"
>
  <Menu data-slot="icon" />
  Components
</Button>

Layout classes (mb-4, lg:hidden) stay on the component — that part is yours. Everything describing how the control looks moves to intent/size. onClick becomes onPress.

A raw search box where a SearchField belongs

Was real code in src/site/component-sidebar.tsx (the docs-site sidebar, not a library primitive), until it was fixed

don't

<div className="relative">
  <Search className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-quebi-fg-subtle" />
  <input
    type="search"
    value={query}
    onChange={(e) => setQuery(e.target.value)}
    placeholder="Search components"
    aria-label="Search components"
    className="w-full rounded-none border border-quebi-hairline bg-transparent py-2 pr-3 pl-9 text-sm text-quebi-fg placeholder:text-quebi-fg-subtle transition-colors duration-200 focus:border-quebi-action focus:outline-none"
  />
</div>

do

import { SearchField, SearchInput } from "@/components/search-field"

<SearchField aria-label="Search components" value={query} onChange={setQuery}>
  <SearchInput placeholder="Search components" />
</SearchField>

Eleven lines become three, and the field gains what the raw input never had: a clear button, Escape-to-clear, and an icon the component positions instead of an absolutely-positioned sibling that has to be kept in sync with the padding. onChange hands you the value rather than an event. The <input> carve-out below is about the layer that owns a control's value, not about a directory name — a sidebar that happens to sit next to the library source is still app code. This one used to sit in src/components/ and was excused by that address until it was moved out; if your app keeps its own chrome next to the vendored library, keep the two apart the same way.

An unstyled button is not the safe case

don't

<button onClick={onClose}>Close</button>

do

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

<Button intent="ghost" onPress={onClose}>
  Close
</Button>

No className, so nothing looks wrong in review — and it still has no focus ring, no press semantics, and no connection to the Dialog context that would close the overlay.

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: Biome reports one message per element, naming that element's own replacement — so <a> tells you about Link and LinkButton rather than listing all nine. The rule also fires inside your copy of the ui-lib components, which is why the documented exception scopes it away; point it at wherever you pasted the source (components/ui by shadcn convention).

Biome — correctness/noRestrictedElements

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": {
      "correctness": {
        "noRestrictedElements": {
          "level": "error",
          "options": {
            "elements": {
              "button": "Use <Button> from @/components/button (performs an action in place), or <LinkButton> from @/components/link-button (navigates — a control that changes the URL must be an anchor), or <Toggle> from @/components/toggle (has an on/off pressed state).",
              "a": "Use <Link> from @/components/link (an inline text link (it renders a plain anchor for http(s)/mailto/tel hrefs, so external links keep working outside a router)), or <LinkButton> from @/components/link-button (a link that should look like a button).",
              "input": "Use <TextField> from @/components/text-field (a labelled field with description and error text), or <Input> from @/components/input (the bare control, inside a Field you compose yourself), or <SearchField> from @/components/search-field (type=search), or <NumberField> from @/components/number-field (type=number — adds steppers and locale-aware parsing), or <Checkbox> from @/components/checkbox (type=checkbox), or <RadioGroup> from @/components/radio (type=radio), or <Switch> from @/components/switch (a boolean rendered as a switch).",
              "select": "Use <Select> from @/components/select (a single choice from a known list), or <ComboBox> from @/components/combo-box (the list is long enough to need typeahead), or <MultipleSelect> from @/components/multiple-select (multiple).",
              "textarea": "Use <Textarea> from @/components/textarea.",
              "form": "Use <Form> from react-router (submitting to a route action), or <ConformField> from @/components/conform-field (binding fields to a Conform form).",
              "label": "Use <Label> from @/components/field (composing a field by hand).",
              "dialog": "Use <Modal> from @/components/modal (a centered overlay), or <Drawer> from @/components/drawer (an edge-anchored panel), or <Sheet> from @/components/sheet (a side sheet), or <Dialog> from @/components/dialog (the dialog content itself (title, body, footer)).",
              "table": "Use <Table> from @/components/table (a static table (with TableHeader, TableBody, TableColumn, TableRow, TableCell)), or <DataTable> from @/components/data-table (the rows you have are all of them — the browser holds the dataset and sorts, filters, pages and groups it), or <ServerTable> from @/components/server-table (the rows are a page of a larger answer — sorting, filtering and paging are queries the server runs), or <TableShell> from @/components/table-shell (neither mode fits and you are bringing a TanStack instance of your own)."
            }
          }
        }
      }
    }
  },
  "overrides": [
    {
      "includes": [
        "src/components/**",
        "components/ui/**"
      ],
      "linter": {
        "rules": {
          "correctness": {
            "noRestrictedElements": "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.

# no-raw-interactive-elements — candidates for review
rg -n -g '*.{jsx,tsx}' \
  -g '!src/components/**' \
  -g '!components/ui/**' \
  "<(a|button|dialog|form|input|label|select|table|textarea)($|[\\s/>])"

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/correctness/noRestrictedElements: A <form> that submits on the client only, with no route action behind it — React Router's Form posts to a route action;

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

<input> inside the ui-lib component source (components/ui/**)
Only <input>, and only there. A component that owns a controlled value has to submit it through a hidden input, and react-aria has no primitive for that — a hidden input is not an interactive control, so none of this rule's reasoning applies to it. Everything else on the list stays banned inside the library too: quebi's Button does not wrap <button>, it wraps react-aria's Button, so there is no layer here that needs the raw element.
A <form> that submits on the client only, with no route action behind it
React Router's Form posts to a route action; where there is none — a filter panel, a wizard step, a Conform form handled entirely in the browser — a raw <form> bound with getFormProps(form) is the right element, and the closest thing to a replacement would be worse. This covers the form element only: everything inside it stays on this list.

scope and enforcement

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