lit-ui-router
lit-ui-router is a UI-Router implementation for Lit: @uirouter/core with Lit view handling, the <ui-router> and <ui-view> elements, and directives and controllers for links and router state. The companion packages build on it.
Installation
npm install lit-ui-router lit @uirouter/coreQuick Start
import {
UIRouterLit,
uiSref,
uiSrefActive,
LitStateDeclaration,
} from 'lit-ui-router';
import { hashLocationPlugin } from '@uirouter/core';
import { html } from 'lit';
// 1. Create router and add location plugin
const router = new UIRouterLit();
router.plugin(hashLocationPlugin);
// 2. Define states
const states: LitStateDeclaration[] = [
{ name: 'home', url: '/home', component: () => html`<h1>Home</h1>` },
{ name: 'users', url: '/users', component: UserListElement },
];
// 3. Register states and start
states.forEach((state) => router.stateRegistry.register(state));
router.urlService.rules.initial({ state: 'home' });
router.start();<!-- 4. Use in your app -->
<ui-router .uiRouter="${router}">
<nav>
<a ${uiSref('home')} ${uiSrefActive({ activeClasses: ['active'] })}>Home</a>
<a ${uiSref('users')} ${uiSrefActive({ activeClasses: ['active'] })}>Users</a>
</nav>
<ui-view></ui-view>
</ui-router>rules.initial({ state: 'home' }) lands on the state with no params; a landing state that reads the query needs the function form — see Unmatched URLs.
Entry Points
| Import | Effect |
|---|---|
import { ... } from 'lit-ui-router' | The router, the elements, the directives and the controllers. Any value import registers the <ui-router>/<ui-view> custom elements as a side effect. |
import { ... } from 'lit-ui-router/pure' | The same API — element classes included — with no registration and no HTMLElementTagNameMap globals. |
import 'lit-ui-router/register' | Registration only: defines <ui-router>/<ui-view> and carries their HTMLElementTagNameMap entries. |
import 'lit-ui-router/ui-view.register' | Single-element registration: defines just that element with its tag-map entry (ui-router.register ditto). |
import { ... } from 'lit-ui-router/context' | The only home of the context-request key, event and request helper, and of the tree-less router hand-off — nothing from lit — see Reactive Components. |
lit-ui-router/custom-elements.json | The Custom Elements Manifest for <ui-router> and <ui-view>, also named by the customElements field. Bundlers load it as a URL or JSON, e.g. ?url in Vite. |
import type { ... } from 'lit-ui-router' | Types are erased at compile time — always free, from any entry. |
These entries are the supported import paths. Deep lit-ui-router/dist/... paths still resolve, but they are unsupported and 2.0 removes them.
The root entry is exactly pure + register: reach for lit-ui-router/pure when you need the APIs (or the element classes themselves, e.g. for scoped registries or custom tag names) without touching the global registry, and pair it with lit-ui-router/register — or a single *.register entry — to opt into registration explicitly.
Development and Production Builds
Every entry point above publishes twice: dist/development/*.js alongside dist/*.js, picked by the development export condition, which bundlers resolve automatically. Nothing to configure.
The development build carries three console warnings — uiSref writing an inert href, uiSrefActive taking over an aria-current it did not set, and any binding finding no <ui-router> ancestor. Each is gated on lit's own development build as well, and none of them, message text included, is present in a production bundle. See Development and Production Builds for what each warning means and how the split is verified.
Component Styles
lit-ui-router supports multiple ways to define route components:
Inline Template Function (simplest)
{ name: 'home', url: '/', component: () => html`<h1>Home</h1>` }Template with Route Parameters
{
name: 'user',
url: '/user/:id',
component: (props) => html`<h1>User ${props?.transition?.params().id}</h1>`
}Template with Resolved Data
{
name: 'users',
url: '/users',
component: (props) => html`
<ul>${props?.resolves?.users?.map(u => html`<li>${u.name}</li>`)}</ul>
`,
resolve: [{ token: 'users', resolveFn: () => fetchUsers() }]
}LitElement Class (for complex components with lifecycle/state)
{ name: 'dashboard', url: '/dashboard', component: DashboardElement }| Style | Best For |
|---|---|
() => html`...` | Simple static views |
(props) => html`...` | Views needing params or resolves |
MyElement | Complex views with lifecycle, state, or styles |
Routed Components covers reading params and typing a state's resolves.
Reacting to Transitions
Any element (routed or not) can stay synchronized with the router using the TransitionController reactive controller. It registers transition hooks when the host connects, calls host.requestUpdate() on matching transitions, and deregisters everything when the host disconnects — no manual requestUpdate() plumbing or leaked hooks.
import { TransitionController } from 'lit-ui-router';
class NavHeader extends LitElement {
private transitions = new TransitionController(this);
render() {
// Re-evaluated after every successful transition
return html`Current state: ${this.transitions.current?.name}`;
}
}Scope it to specific states or react to parameter changes with a callback:
class UserDetail extends LitElement {
private transitions = new TransitionController(this, {
criteria: { to: 'users.detail' },
callback: () => this.loadUser(this.transitions.params.userId),
});
}See Reactive Components.
Navigation Links
uiSref and uiSrefActive bind a link from the element; srefHref, srefActiveClass and srefAriaCurrent bind the same link from inside the attribute it writes.
uiSref- Creates navigation links to statesuiSrefActive- Adds CSS classes when linked state is active, and setsaria-currenton active linkssrefHref-uiSrefbound in thehrefattributesrefActiveClass-uiSrefActive's classes, bound in theclassattributesrefAriaCurrent-uiSrefActive'saria-current, bound in the attribute
Attribute-part forms
uiSref and uiSrefActive are element parts: they sit on the element and write to it from the outside, so a reader that is not a live browser — a server renderer, an accessibility linter — sees an <a> with no href. The sref* directives do the same jobs from inside the attribute they affect, so the template says what the browser will show. A linter reads that, and so does a server render given a router — see Server-Side Routing:
<a href=${srefHref('users')}
class="nav-link ${srefActiveClass({ state: 'users', activeClasses: ['active'] })}"
aria-current=${srefAriaCurrent({ state: 'users' })}>Users</a>
<!-- while at `users`: <a href="/users" class="nav-link active" aria-current="page"> -->
<!-- while at `users.detail`: <a href="/users" class="nav-link active"> -->srefHreftakesuiSref's arguments and does everythinguiSrefdoes — the click navigates, and an enclosinguiSrefActivestill tracks it. There is noassignHref: the attribute is the binding. Use one form or the other on an element, not both.srefActiveClassfollows lit'sclassMap: bind it inclass, alone or beside static classes, and it toggles only the classes it names. It cannot share the attribute withclassMapitself, so pass those classes asclasses: { 'nav-link': true, disabled: locked }. Leavestateout on a wrapper to watch thesrefHreflinks inside it, asuiSrefActivedoes.srefAriaCurrentis the one piece aclassbinding cannot reach, so it is its own directive. Binding the attribute is the opt-in: it writes'page'while the exact state is active and removes the attribute otherwise, on any element, with none ofuiSrefActive's link detection or takeover rules.valuepicks another token or{ exact, active }for ancestors.
Composing with classMap
A class attribute holds one toggling directive, so srefActiveClass and classMap cannot share it. When a component wants both — the active flag next to its own bindings — SrefStatusController hands the status to the host instead of writing an attribute, and the template composes it freely:
@property({ type: Boolean }) locked = false;
private users = new SrefStatusController(this, { state: 'users' });
// class=${classMap({ 'nav-link': true, active: this.users.active, disabled: this.locked })}
// aria-current=${this.users.ariaCurrent()}See Reactive Components.
Accessible active links
uiSrefActive conveys active state to assistive technology as well as to CSS. On a link element (<a>, <area>, or anything with role="link") it sets aria-current="page" while the exact linked state is active, and removes the attribute when it is not — so the nav above needs no extra markup:
<a ${uiSref('users')} ${uiSrefActive({ activeClasses: ['active'] })}>Users</a>
<!-- while at `users`: <a href="/users" class="active" aria-current="page"> -->
<!-- while at `users.detail`: <a href="/users" class="active"> -->aria-current="page" is applied on exact match only, deliberately: an ancestor state being active means the link points at a section containing the current page, not at the current page itself, and a nav in which several ancestor links all claim aria-current="page" is worse for a screen reader user than one with none.
Three knobs, via ariaCurrentValue:
Another token —
'page'(default),'step','location','date','time', or'true'. Passing a value explicitly also opts non-link elements in, which is otherwise off;aria-currenton a wrapping<li>or<tr>is valid ARIA but rarely what an author means, so wrappers stay silent unless asked.html<li ${uiSrefActive({ activeClasses: ['active'] })}> <!-- classes on the wrapper, aria-current on the link --> <a ${uiSref('users')} ${uiSrefActive({})}>Users</a> </li> <!-- an explicit value opts a non-link element in; 'auto' keeps href off it --> <tr ${uiSref('.message', { messageId }, { assignHref: 'auto' })} ${uiSrefActive({ activeClasses: ['active'], ariaCurrentValue: 'true' })}></tr>false— leavearia-currentalone entirely; the directive will neither set nor remove it. This is the opt-out to reach for when the application managesaria-currentitself: a value written in the template survives untouched, in every routing state.html<a ${uiSref('home')} ${uiSrefActive({ activeClasses: ['active'], ariaCurrentValue: false })}>Home</a>{ exact, active }— mark ancestors too, which is otherwise off.activeapplies while a child state is active and this one is not the exact match;'location'is the ARIA token meant for exactly that.html<a ${uiSref('users')} ${uiSrefActive({ activeClasses: ['active'], ariaCurrentValue: { exact: 'page', active: 'location' }, })}>Users</a> <!-- while at `users`: aria-current="page" --> <!-- while at `users.detail`: aria-current="location" -->Note this pair does not combine the way
activeClassesandexactClassesdo. Both class sets land inclasson an exactly-active link, because an exact match is also an active one.aria-currentis a single attribute with a single value, so the two are branches of one decision: an exactly-active element takesexactand never falls through toactive. Each key keeps its own default when omitted, so{ active: 'location' }alone still defaultsexactto'page'on links — write{ exact: false, active: 'location' }to mark only ancestors.
The directive only removes an aria-current it wrote itself. A value authored in the template is therefore left alone — but only until the directive first writes one of its own, after which it owns the attribute and will clear it on the next inactive render. ariaCurrentValue: false is the way to keep a template-authored value for good, since the directive then never writes and never takes ownership.
Router and Views
Router
UIRouterLit is the main router class. It extends @uirouter/core's UIRouter with Lit-specific view handling.
viewRendered(router, transition?) resolves once every <ui-view> registered with the router, and the element each one rendered, has completed its update for the transition. It waits on updateComplete, never on a frame — see Component Lifecycle.
Elements
<ui-router>- Root component that provides router context to descendants<ui-view>- Viewport that renders the component for the current state
Lifecycle Hooks
Routed components can implement these interfaces to respond to routing events; Component Lifecycle Hooks covers both:
UiOnExit- Called before navigating away (can cancel navigation)UiOnParamsChanged- Called when route parameters change
Injected Props
Routed components receive UIViewInjectedProps with:
router- The UIRouter instancetransition- The current transitionresolves- Resolved data from state declarations
Routed Components covers what a routed component reads from them.
Further reading
- API reference
- Tutorial
- Location Plugins — hash, pushState and Navigation API URLs
- Server-Side Routing — the router hand-off in
lit-ui-router/context - Companion packages
- @uirouter/core docs