---
url: https://lit-ui-router.dev/packages/eslint-plugin-lit-ui-router.md
description: >-
  Directive-aware linting with eslint-plugin-lit-ui-router — rules covering
  hrefless anchors, inert hrefs, aria-current conflicts and misplaced directives
---

# eslint-plugin-lit-ui-router

[`eslint-plugin-lit-ui-router`](https://npmx.dev/package/eslint-plugin-lit-ui-router)
is a small set of ESLint rules that understand lit-ui-router's directives.

A lit-ui-router anchor carries no static `href` — the
[`uiSref`](/reference/lit-ui-router/directives/uiSref) element part assigns one at
runtime — so stock accessibility rules report every correct call site. The
usual escape is to disable the rule, which costs its real coverage. This
plugin instead ships its own copy of the base rule, taught that the directive
counts as the `href` it assigns, and reports everything the base rule would
otherwise still catch.

That vendored rule is the origin story rather than the whole package. The
others cover the mistakes the directives make *possible*, rather than the
ones they mask: an `href` written to an element that has none, an
`aria-current` the directive silently takes over, an `aria-current` nothing
writes at all, and a directive used outside the part type it accepts.

## Installation

```bash
npm install --save-dev eslint-plugin-lit-ui-router
```

`eslint` (`^9.0.0 || ^10.0.0`) is the only **peer** dependency.
`anchor-is-valid` is vendored from
[`eslint-plugin-lit-a11y`](https://www.npmjs.com/package/eslint-plugin-lit-a11y)
rather than wrapped around it, so lit-a11y is an **optional sibling** you may
also run for the rest of its rules, never a requirement.

The package is published as ESM only. Flat config loads ESM natively; a
CommonJS config can `require()` it on Node `^20.19.0` or `>=22.12.0`; the
call returns the module namespace, so read the plugin from its `.default`.

## Usage

Flat config (`eslint.config.js`), with lit-a11y alongside:

```js
import litA11y from 'eslint-plugin-lit-a11y';
import litUiRouter from 'eslint-plugin-lit-ui-router';

export default [
  litA11y.configs.recommended,
  ...litUiRouter.configs.recommended,
];
```

Order matters: `configs.recommended` must come **after** lit-a11y's own
config, because it turns `lit-a11y/anchor-is-valid` off in favor of ours. It
deliberately does not register the `lit-a11y` plugin key itself — the host's
instance owns it. Without lit-a11y installed that `off` line is simply inert,
so the ordering rule costs nothing either way.

## The rules

| Rule                                                                                                                                                                        | What it catches                                                                                                                                                                                                            |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`anchor-is-valid`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/anchor-is-valid.md)                               | an anchor with no `href` — where a `uiSref` element part counts as one                                                                                                                                                     |
| [`sref-assign-href`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/sref-assign-href.md)                             | an inert `href` written to a `<button>`, `<tr>` or `<div>`; fix adds `'auto'`                                                                                                                                              |
| [`sref-active-aria-current`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/sref-active-aria-current.md)             | an authored `aria-current` the directive silently takes over and later removes                                                                                                                                             |
| [`sref-active-class-aria-current`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/sref-active-class-aria-current.md) | a link `srefActiveClass` paints active with no `aria-current` beside it; fix binds `srefAriaCurrent`                                                                                                                       |
| [`sref-status-aria-current`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/sref-status-aria-current.md)             | a link whose classes read a `SrefStatusController` with no `aria-current` beside it; fix binds `ariaCurrent()`                                                                                                             |
| [`directive-position`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/directive-position.md)                         | a directive outside the part it accepts — `uiSref` and `uiSrefActive` on the element, `srefHref` and `srefAriaCurrent` as a whole attribute value, `srefActiveClass` in `class`; each throws on first render anywhere else |

All six are in `configs.recommended` at `error`. The last five are the
directives' own runtime development warnings, gaps and throws, said
statically: they report at author time, across the whole codebase, and in a
production build — where the runtime says nothing at all.

Three of them are one concern seen from three angles. A link can learn it is
active from the [`uiSrefActive`](/reference/lit-ui-router/directives/uiSrefActive)
element part, from a
[`srefActiveClass`](/reference/lit-ui-router/directives/srefActiveClass) attribute part,
or from a
[`SrefStatusController`](/reference/lit-ui-router/controllers/SrefStatusController) the
host composes with `classMap` — and only the first writes `aria-current`
itself. `sref-active-aria-current` protects the authored
attribute from the element part's takeover, `sref-active-class-aria-current`
asks for the attribute the attribute part never writes, and
`sref-status-aria-current` asks for it where the status never reaches the
template as a directive call at all.

Every rule is syntax-only, with no type information, so they also load into
[oxlint](https://oxc.rs) as JS plugins.

## What `anchor-is-valid` does

`lit-ui-router/anchor-is-valid` is lit-a11y's `anchor-is-valid`, vendored from
`eslint-plugin-lit-a11y@5.1.1` and extended so a `uiSref` element part counts
as the href it assigns at runtime. lit-a11y is an optional sibling you may
also run, not a dependency — our `recommended` turns its copy off so only one
of them reports. The extension suppresses exactly those reports and nothing
else:

```js
import { html } from 'lit';
import { uiSref } from 'lit-ui-router';

// Fine: the directive assigns the href.
html`<a ${uiSref('home')}>Home</a>`;

// Still reported, exactly as the base rule would.
html`<a>Home</a>`;
html`<a @click=${() => {}}>Home</a>`;
html`<a ${uiSref('home', undefined, { assignHref: false })}>Home</a>`;
```

An anchor counts as navigable when its `uiSref` is imported from
`lit-ui-router` or a module listed in `settings.litUiRouterSources` — a
foreign package's `uiSref` proves nothing — and the call
doesn't opt out of href assignment. Only a literal `assignHref: false` is a
definite no: `'auto'` assigns on a native `<a>`, and a non-literal option is
unknowable, so both stay suppressed rather than guessed. The base rule's
options (`allowHash`, `aspects`) are carried over untouched.

`settings.litUiRouterSources` adds modules whose exports count as
lit-ui-router's, such as an app's typed `uiSref` wrapper. Entries match an
import specifier as written, itself or a subpath, and are never resolved, so a
path alias or package name is the form to list. The
[settings reference](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/README.md#settings)
covers matching and the signature a listed export is assumed to have.

`@lit-labs/ssr` never runs an element part, so a prerendered `uiSref` anchor
is served with no `href`. With `settings.allowElementParts` false,
`anchor-is-valid` and `sref-active-class-aria-current` lint a template as
served: the fix rewrites a lone `uiSref` as `href=${srefHref(...)}`, and a
`uiSrefActive` link reports until it binds `srefActiveClass` and
`srefAriaCurrent`. The
[prerendered templates recipe](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/README.md#prerendered-templates)
scopes it with `files`, for a mostly-client app and a fully isomorphic one.

The generated
[rule documentation](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/anchor-is-valid.md)
is the reference: every example above, the option table, and the
configuration the rule ships in.

## Example

[`examples/lint-eslint`](https://github.com/simshanith/lit-ui-router/tree/main/examples/lint-eslint)
is the consumer wiring, installed from npm exactly as a consumer would:
`eslint.config.js` spreads `litA11y.configs.recommended` first and
`...litUiRouter.configs.recommended` after, and a local Vite plugin runs
ESLint's Node API at build time and renders the result in the page — a custom
panel and an iframe of ESLint's own `html` formatter, over the same results.

`src/violations.ts` is a gallery with one deliberate violation per
`recommended` rule, so the report is not empty: the embed below is that built
page, and its report is static (`11 problems` — one per rule, the
`settings.linkElements` pair on `<sp-link>`, the `settings.litUiRouterSources`
pair on a `#router` wrapper, and three from prerendered
templates linted with `settings.allowElementParts` false, each rule id linked
to its docs).
Open it on StackBlitz to edit the sources and watch the report re-run.

The ESLint Plugin example: [source on GitHub](https://github.com/simshanith/lit-ui-router/tree/main/examples/lint-eslint), [open in StackBlitz](https://stackblitz.com/github/simshanith/lit-ui-router/tree/main/examples/lint-eslint?file=src%2Fmain.ts).

For the positive control, delete `${uiSref('eslint-html')}` from the second
tab in `src/main.ts`: `lit-ui-router/anchor-is-valid` now reports against the
app itself rather than the gallery, as an error — and the tab stops
navigating, which is the whole reason the rule exists.

The example is the npm-installed end of a check this repository runs on
itself: oxlint loads the same six rules as JS plugins and holds the whole repo
to them at `error`, sample apps included, where every navigation anchor is a
`uiSref` call site.

* [Open on StackBlitz](https://stackblitz.com/github/simshanith/lit-ui-router/tree/main/examples/lint-eslint)
* [Source on GitHub](https://github.com/simshanith/lit-ui-router/tree/main/examples/lint-eslint)

## Semver policy

Following ESLint core's own policy: a change that makes `recommended` or an
existing rule stricter — new reports on code that previously passed — ships
as a **major**.

New router surface is the exception. lit-ui-router keeps adding directives,
and a rule that can only fire on one of them reports on nothing anybody could
already have written — the API it matches did not exist yet. Those rules are
additive, so they ship as a **minor**. The attribute-part tier is the current
example:
[`sref-active-class-aria-current`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/sref-active-class-aria-current.md),
[`sref-status-aria-current`](https://github.com/simshanith/lit-ui-router/blob/main/packages/eslint-plugin-lit-ui-router/docs/rules/sref-status-aria-current.md)
and `directive-position`'s attribute rows match only
[`srefHref`](/reference/lit-ui-router/directives/srefHref),
[`srefActiveClass`](/reference/lit-ui-router/directives/srefActiveClass),
[`srefAriaCurrent`](/reference/lit-ui-router/directives/srefAriaCurrent) and
[`SrefStatusController`](/reference/lit-ui-router/controllers/SrefStatusController)
imported from `lit-ui-router`, all new in `lit-ui-router@1.14`. A codebase on
an earlier router cannot trip them, which is why they arrive in `1.1` rather
than `2.0`.

What bounds the exception is the surface a rule *can* reach, not how rare a
report would be in practice. A new report reachable through `uiSref` or
`uiSrefActive` — shipped since `1.0` — is a major however unlikely it is, as
is a tightened option default, and so is a rule for router API that shipped
but went undocumented: the line is that the router did not export it, not that
nobody used it.

The option-aware tier widened `recommended` during the 1.0.0 release
candidates, where a widening cost nobody a major; the remaining roadmap (a
state-aware tier) is the same trajectory, so expect majors rather than silent
tightening, and pin accordingly.

## Releases

## Further reading

* [Design System Links](/guides/design-system-links) — what `uiSref` does to
  an anchor, and why the href only exists at runtime
* [`uiSref` API](/reference/lit-ui-router/directives/uiSref) — the directive itself,
  including `assignHref`
