Run the rules, don't just read them
All 8 rules as one Biome setup, exceptions included. It is rebuilt from the rules whenever they change, so re-download it rather than maintaining a copy by hand — that way a rule we sharpen reaches your CI. Enforcing one rule at a time? Each rule's page carries its own snippet, plus a ripgrep one-liner for projects with no linter at all.
recommended rules, so dropping it in cannot silently change what else your project lints. You own all of it once it lands — soften a rule to warn, scope it with overrides, or drop an entry you disagree with.Wire it up
# 1. Biome, if the project does not have it yet
npm i -D @biomejs/biome
# 2. The GritQL plugins — one per rule Biome has no built-in for
mkdir -p ui-lib-rules
curl -o ui-lib-rules/no-appearance-classes-on-layout-elements.grit https://ui-lib.quebi.de/api/rules/plugins/no-appearance-classes-on-layout-elements.grit
curl -o ui-lib-rules/no-hardcoded-design-values.grit https://ui-lib.quebi.de/api/rules/plugins/no-hardcoded-design-values.grit
curl -o ui-lib-rules/bind-fields-through-conform.grit https://ui-lib.quebi.de/api/rules/plugins/bind-fields-through-conform.grit
curl -o ui-lib-rules/render-field-text-through-the-field.grit https://ui-lib.quebi.de/api/rules/plugins/render-field-text-through-the-field.grit
curl -o ui-lib-rules/validate-on-the-server-with-the-same-schema.grit https://ui-lib.quebi.de/api/rules/plugins/validate-on-the-server-with-the-same-schema.grit
# 3. The config — merge these keys into your biome.jsonc
curl -O https://ui-lib.quebi.de/api/rules/biome.jsonc
npx biome lint src
How Biome carries each rule
Two mechanisms, and the difference decides how a rule's documented exceptions are applied.
- Built-in rules
- Interactive elements correctness/noRestrictedElementsPrimitive imports style/noRestrictedImportsFile length style/noExcessiveLinesPerFile
Configured in
biome.jsonc, so their exceptions are ordinaryoverrides— Biome's own path scoping. - GritQL plugins
- Layout elements no-appearance-classes-on-layout-elements.grit
- Design values no-hardcoded-design-values.grit
- Field binding bind-fields-through-conform.grit
- Labels and errors render-field-text-through-the-field.grit
- Server validation validate-on-the-server-with-the-same-schema.grit
Biome loads plugins globally and
overridesdoes not scope them, so each plugin carries its own exceptions as$filenameguards compiled into the pattern. Same records, same carve-outs — a different mechanism because the tool requires one.
biome.jsonc — all 8 rules
/api/rules/biome.jsonc// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules.json — do not edit by hand.
//
// Every rule, message, and exception below comes from a rule record in quebi
// ui-lib, so this file and https://ui-lib.quebi.de/rules cannot drift apart.
//
// Merge these keys into your own biome.jsonc. The `plugins` entries are
// GritQL files served alongside this one — fetch them into ./ui-lib-rules/:
// curl -o ui-lib-rules/no-appearance-classes-on-layout-elements.grit https://ui-lib.quebi.de/api/rules/plugins/no-appearance-classes-on-layout-elements.grit
// curl -o ui-lib-rules/no-hardcoded-design-values.grit https://ui-lib.quebi.de/api/rules/plugins/no-hardcoded-design-values.grit
// curl -o ui-lib-rules/bind-fields-through-conform.grit https://ui-lib.quebi.de/api/rules/plugins/bind-fields-through-conform.grit
// curl -o ui-lib-rules/render-field-text-through-the-field.grit https://ui-lib.quebi.de/api/rules/plugins/render-field-text-through-the-field.grit
// curl -o ui-lib-rules/validate-on-the-server-with-the-same-schema.grit https://ui-lib.quebi.de/api/rules/plugins/validate-on-the-server-with-the-same-schema.grit
//
// Biome's overrides do not scope plugins, so each plugin carries its own
// exceptions as $filename guards inside the pattern. The overrides below
// therefore only cover the built-in rules.
{
"plugins": [
"./ui-lib-rules/no-appearance-classes-on-layout-elements.grit", // Layout elements are yours — until their classes describe appearance
"./ui-lib-rules/no-hardcoded-design-values.grit", // Design values come from quebi tokens, never from literals
"./ui-lib-rules/bind-fields-through-conform.grit", // Bind fields through Conform, never by hand
"./ui-lib-rules/render-field-text-through-the-field.grit", // Label, description and error come from the field — not from markup beside it
"./ui-lib-rules/validate-on-the-server-with-the-same-schema.grit" // Validate on the server with the same schema
],
"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)."
}
}
}
},
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"react-aria-components": {
"importNames": [
"Autocomplete",
"Breadcrumb",
"Breadcrumbs",
"Button",
"Calendar",
"CalendarCell",
"CalendarGrid",
"CalendarGridBody",
"CalendarGridHeader",
"CalendarHeaderCell",
"CalendarStateContext",
"Cell",
"Checkbox",
"CheckboxGroup",
"Collection",
"CollectionRendererContext",
"ColorArea",
"ColorField",
"ColorPicker",
"ColorPickerStateContext",
"ColorSlider",
"ColorSwatch",
"ColorSwatchPicker",
"ColorSwatchPickerItem",
"ColorThumb",
"ColorWheel",
"ColorWheelTrack",
"Column",
"ColumnResizer",
"ComboBox",
"ComboBoxContext",
"DateField",
"DateInput",
"DatePicker",
"DateRangePicker",
"DateSegment",
"DefaultCollectionRenderer",
"Dialog",
"DialogTrigger",
"Disclosure",
"DisclosureGroup",
"DisclosurePanel",
"DisclosureStateContext",
"DropZone",
"FieldError",
"FileTrigger",
"GridList",
"GridListHeader",
"GridListItem",
"GridListSection",
"Group",
"Header",
"Heading",
"Input",
"Keyboard",
"Label",
"Link",
"ListBox",
"ListBoxItem",
"ListBoxSection",
"Menu",
"MenuItem",
"MenuSection",
"MenuTrigger",
"Meter",
"Modal",
"ModalContext",
"ModalOverlay",
"NumberField",
"OverlayArrow",
"OverlayTriggerStateContext",
"Popover",
"Pressable",
"ProgressBar",
"Radio",
"RadioGroup",
"RangeCalendar",
"RangeCalendarStateContext",
"ResizableTableContainer",
"Row",
"SearchField",
"Select",
"SelectValue",
"Separator",
"Slider",
"SliderOutput",
"SliderStateContext",
"SliderThumb",
"SliderTrack",
"SubmenuTrigger",
"Switch",
"Tab",
"TabList",
"TabPanel",
"TabPanels",
"Table",
"TableBody",
"TableHeader",
"Tabs",
"Tag",
"TagGroup",
"TagList",
"Text",
"TextArea",
"TextField",
"TimeField",
"ToggleButton",
"ToggleButtonGroup",
"Toolbar",
"Tooltip",
"TooltipTrigger",
"Tree",
"TreeItem",
"TreeItemContent"
],
"message": "react-aria-components is ui-lib's dependency, not yours: import the quebi component instead (Button -> @/components/button, TextField -> @/components/text-field, and so on). Helpers the library does not wrap, like parseColor, and type-only imports stay allowed. See https://ui-lib.quebi.de/rules/import-components-not-primitives"
}
}
}
},
"noExcessiveLinesPerFile": {
"level": "warn",
"options": {
"maxLines": 500
}
}
}
}
},
"overrides": [
{
"includes": [
// no-raw-interactive-elements — Only <input>, and only there.
// no-appearance-classes-on-layout-elements — Components are made of appearance-styled divs — that is what a component is.
// no-hardcoded-design-values — The components resolve palette scales deliberately — a danger state on red-500, a Badge intent on emerald-500 — and they are not yours to re-token.
// import-components-not-primitives — This is the layer that imports the primitives — it is what makes the components components.
// keep-files-readable — You did not write it and cannot split it without forking it.
// bind-fields-through-conform — The conform-* variants are where getInputProps is called and the metadata is spread onto a control.
// render-field-text-through-the-field — The conform-* variants and the field primitives are where this markup is supposed to live — they are the layer that renders the label, the description, and the error, and wires the ids between them.
"components/ui/**",
// no-raw-interactive-elements — Only <input>, and only there.
// no-appearance-classes-on-layout-elements — Components are made of appearance-styled divs — that is what a component is.
// no-hardcoded-design-values — The components resolve palette scales deliberately — a danger state on red-500, a Badge intent on emerald-500 — and they are not yours to re-token.
// import-components-not-primitives — This is the layer that imports the primitives — it is what makes the components components.
// keep-files-readable — You did not write it and cannot split it without forking it.
// bind-fields-through-conform — The conform-* variants are where getInputProps is called and the metadata is spread onto a control.
// render-field-text-through-the-field — The conform-* variants and the field primitives are where this markup is supposed to live — they are the layer that renders the label, the description, and the error, and wires the ids between them.
"src/components/**"
],
"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).",
"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)."
}
}
}
},
"style": {
"noRestrictedImports": "off",
"noExcessiveLinesPerFile": "off"
}
}
}
}
]
}