Element usage

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.

Tier 1errorlintno-raw-interactive-elements

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
<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)
  • AsyncTable from @/components/async-tablesorting and filtering are server-driven

Wrong / right

A raw anchor where a LinkButton belongs

Real code from src/routes/_index.tsx (hero GitHub link)

Don't

<a
  href="https://github.com/quebi-gmbh"
  target="_blank"
  rel="noreferrer"
  className="inline-flex items-center gap-2 rounded-quebi-sm border border-quebi-line/20 px-6 py-3 text-quebi-fg transition-colors duration-200 hover:border-quebi-brand hover:text-quebi-brand"
>
  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

Real code from src/routes/components.tsx (mobile sidebar toggle)

Don't

<button
  type="button"
  onClick={() => setMobileOpen((o) => !o)}
  className="mb-4 inline-flex items-center gap-2 rounded-quebi-sm border border-quebi-line/20 px-3 py-2 text-sm text-quebi-fg-muted transition-colors duration-200 hover:border-quebi-brand hover:text-quebi-brand 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.

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 <AsyncTable> from @/components/async-table (sorting and filtering are server-driven)."
            }
          }
        }
      }
    }
  },
  "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 '*.{tsx,jsx}' \
  -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; */}

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