Hone UI

ESLint plugin

Five ESLint rules that catch invalid prop values, missing accessible names, a broken renderInput and sx colors that ignore dark mode.

@hone-ui/eslint-plugin flags wrong Hone UI usage while you lint. The rules read the props manifest of the @hone-ui/react your app has installed, so they match the version you use.

@hone-ui/eslint-plugin is not published yet, so the install command below works only once the package is on npm.

Install

The plugin needs ESLint 9 or later.

npm i -D eslint typescript-eslint @hone-ui/eslint-plugin
# or
pnpm add -D eslint typescript-eslint @hone-ui/eslint-plugin

The rules read JSX, so ESLint needs a parser that understands it. For TypeScript that is typescript-eslint's parser, which the typescript-eslint package provides and the config below imports. Skip typescript-eslint only if you lint plain JavaScript. Add the recommended config after the parser.

eslint.config.mjs
import tseslint from 'typescript-eslint';
import hone from '@hone-ui/eslint-plugin';

export default [
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: { parser: tseslint.parser },
  },
  hone.configs.recommended,
];

For plain JavaScript, set languageOptions.parserOptions.ecmaFeatures.jsx to true instead of using a parser. The recommended config turns on six rules as error and sx-hardcoded-color as warn. To pick rules one by one, register the plugin under @hone-ui and set each rule in rules.

Rules

valid-prop-value

Reports a literal value that the component does not accept. It checks closed string unions and number props that have a minimum or maximum. The suggestion replaces the string with the closest valid value.

// Invalid
<Button size="huge">Save</Button>

// Valid
<Button size="large">Save</Button>

icon-button-label

Requires aria-label or aria-labelledby on IconButton. A parent Tooltip with a text title names the button, unless it sets describeChild. The rule has no fix, because only you know the label.

// Invalid
<IconButton><DeleteIcon /></IconButton>

// Valid
<IconButton aria-label="Delete"><DeleteIcon /></IconButton>

field-label

Requires an accessible name on TextField, Select, Slider, Checkbox, Radio, Switch, Tabs, Dialog and the input that an Autocomplete renders. A <label htmlFor> in the same file with a matching literal id also counts. The rule has no fix.

A Checkbox, Radio or Switch takes its name from a FormControlLabel around it, or from aria-label or aria-labelledby in slotProps.input or inputProps. An aria-label on the toggle itself lands on the root label, not on the input, so the rule reports it. Tabs takes aria-label or aria-labelledby. A Dialog takes a DialogTitle child, aria-label or aria-labelledby; the rule stays quiet when a child is a custom component or an expression, because either may render the title.

Select takes its name from labelId, aria-label or aria-labelledby. Its label prop only cuts the notch in the outline, so it does not count. Slider takes aria-label, aria-labelledby or getAriaLabel.

// Invalid
<TextField />

// Valid
<TextField label="Email" />
// Invalid
<Switch />
<Checkbox aria-label="Select row" />

// Valid
<FormControlLabel control={<Switch />} label="Wi-Fi" />
<Checkbox slotProps={{ input: { 'aria-label': 'Select row' } }} />

render-input-params

Requires an Autocomplete renderInput to use its params. Without them the input loses its ref, id and handlers. When the function returns a TextField, the suggestion inserts {...params}.

// Invalid
<Autocomplete options={options} renderInput={(params) => <TextField label="City" />} />

// Valid
<Autocomplete options={options} renderInput={(params) => <TextField {...params} label="City" />} />

sx-scheme-function

Reports theme.palette inside an sx function. That object holds the values of one color scheme, so the color stays fixed in dark mode. The suggestion rewrites it to theme.vars.palette, which follows the active scheme. Members that exist only on theme.palette, such as mode and getContrastText, are left alone.

// Invalid
<Box sx={(theme) => ({ color: theme.palette.primary.main })} />

// Valid
<Box sx={(theme) => ({ color: theme.vars.palette.primary.main })} />

no-system-props

Reports a system prop such as fontWeight={600} or p={2} on Box, Stack and Typography. Those components do not support system props, so the value does nothing. Move it into sx. The rule has no fix. width, height and border are left alone because they are plain HTML attributes, and so is any prop the component owns.

// Invalid
<Typography fontWeight={600}>Title</Typography>

// Valid
<Typography sx={{ fontWeight: 600 }}>Title</Typography>

sx-hardcoded-color

Reports a literal hex, rgb(), hsl() or similar color in sx, on color keys such as color, bgcolor and borderColor and in border and background shorthands. A fixed color ignores the theme and dark mode. Use a palette key such as primary.main, or a var(--hn-palette-*) variable. Named colors and var() values are not reported.

// Invalid
<Box sx={{ color: '#ff0000' }} />

// Valid
<Box sx={{ color: 'error.main' }} />

How the rules find @hone-ui/react

A rule reads node_modules/@hone-ui/react/dist/editor-manifest.json. It starts in the folder of the file being linted and walks up to the nearest node_modules/@hone-ui/react. In a monorepo, each app is checked against its own installed version. A file with no absolute path, as with eslint --stdin, starts from ESLint's working folder.

The plugin caches the manifest and reloads it when its modification time changes, so an upgrade needs no ESLint restart. If @hone-ui/react is not installed there, or its manifest is missing, valid-prop-value reports nothing. The label rules do not need the manifest.

A rule tracks only components imported from @hone-ui/react. It accepts a named import, a namespace import and a default import from a subpath such as @hone-ui/react/Button. Renamed imports work. Type-only imports, local components and names that an inner scope shadows are ignored.

What the rules skip

The rules report only what they can read. When they cannot know, they stay quiet.

  • An element with a spread attribute, such as <IconButton {...props} />. The spread may carry the label or the value.
  • A value that is not a literal, such as size={size}, a template string with an expression or a conditional.
  • An open union, such as color, which accepts any palette key, and a prop typed as a plain string.
  • An object prop that is a variable or holds a spread, such as inputProps={{ ...p }}. It may carry the name.
  • A renderInput that is not an inline function, destructures its first parameter or returns something other than JSX.
  • A renderInput element that spreads something besides the params.
  • An sx function that destructures its theme parameter.

AI guide

An AI guide for the plugin ships at @hone-ui/eslint-plugin/ai/eslint-plugin.md and is served at /ai/eslint-plugin.md. The MCP server runs the same rules through its check_usage tool.

On this page