A modern router for Web Components and Lit, built around native browser
capabilities such as URLPattern, the Navigation API, and the
View Transitions API.
Docs:
- English guide: docs/guide.md
- Chinese guide: docs/guide.zh-CN.md
- English API reference: docs/api.md
- 中文 API 参考: docs/api.zh-CN.md
- Changelog: CHANGELOG.md
- Route trees are declared in TypeScript, not scattered across HTML markers.
- Route trees can be updated at runtime by inserting, deleting, or replacing branches.
- Nested routes are rendered through native named slots.
- Guard redirects, leave guards, lazy loading, and route commits are race-safe.
- RouterView exposes direction-aware commits, fallback slots, and loading lifecycle events without adding wrapper components.
- Same-origin link interception is limited to the configured
basePath. - Route components receive an explicit
RouteContextcontract.
deno add jsr:@elelcode/lit-routerLocal development uses the Deno toolchain:
deno task fmt:check
deno task lint
deno task check
deno task test- Overview and quick start: this page
- English guide
- 中文指南
- English API reference
- 中文 API 参考
- Slot outlets design
- Changelog
import { Router } from "@elelcode/lit-router";
import "@elelcode/lit-router";
const router = new Router({
routes: [
{ path: "", component: "home-page" },
{
path: "settings",
component: "settings-layout",
children: [
{ path: "", component: "settings-home" },
{ path: "profile", component: "settings-profile" },
],
},
],
});
document.querySelector("router-view")!.router = router;<router-view></router-view>Each route is a RouteDefinition:
const routes = [
{
path: "users/:id",
title: "User :id",
component: "user-page",
meta: { requiresAuth: true },
props: { pageSize: 20 },
guard: ({ leaf, params }) =>
leaf?.meta?.requiresAuth && params.id === "me" ? "/profile" : true,
load: () => import("./pages/user-page.ts"),
},
];Supported fields:
id: optional stable tree-management identifier used for runtime route-tree updates.name: optional stable route name used byrouter.link()and named navigation.path: route path segment. Use""for index routes and"*"for catch-all.slot: parent slot name used by this route. Defaults to"route-child";"404"and"error"are reserved fallback slot names.title: optional document title template.:paramplaceholders are expanded.viewTransitionName: optional CSSview-transition-nameused by<router-view>for this route when it is the leaf route.component: custom element tag name or a factory function.children: nested route tree rendered through a named slot.meta: route metadata for guards, events, and route context. It is not assigned to the rendered DOM element.props: extra properties assigned to the rendered element with a shallowObject.assign.guard: async or sync route guard. Returnfalseto block, or a string / URL / redirect object to redirect.beforeLeave: async or sync leave guard for the branch being unloaded. Returnfalseto stay on the current page, or a redirect to reroute instead.load: async loader used for first-entry code splitting. It receives{ signal }, so fetches or imports can be cancelled when navigation becomes stale.
Route matching is compiled with specificity ordering, so static segments win over params, and params win over catch-all routes even if the declarations are not listed in that order.
Insert or delete route branches when auth, feature flags, or plugin modules change:
router.insertRoutes([
{ path: "admin", name: "admin", component: "admin-page" },
]);
router.insertRoutes([
{ id: "settings-audit-plugin", path: "audit", component: "audit-page" },
], {
parentId: "settings-shell",
});
router.removeRoute({ id: "settings-audit-plugin" });Inserted child routes still require the parent route component to expose the configured child slot.
Use name for navigation and reverse routing. Use id when you need to manage
route-tree branches that do not need a public navigation name.
If you need to stage multiple changes, batch them into a single refresh:
router.batchRouteUpdates(() => {
router.insertRoutes([
{ path: "alpha", name: "alpha", component: "alpha-page" },
]);
router.insertRoutes([
{ path: "beta", name: "beta", component: "beta-page" },
]);
});If the batch callback throws, the router rolls back that batch instead of leaving a partially applied route tree.
The batch callback must stay synchronous. If you need async work, finish it
first and then enter batchRouteUpdates().
If you need to swap the whole tree at once, you can still replace it directly:
router.setRoutes([
{ path: "", component: "home-page" },
{ path: "admin", component: "admin-page" },
]);router.configure({ routes }) remains available when you need to update
multiple router options together.
Route components should use the explicit RouteContext contract:
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { RouteContext } from "@elelcode/lit-router";
@customElement("user-page")
class UserPage extends LitElement {
@property({ attribute: false })
accessor routeContext: RouteContext | undefined;
render() {
return html`
<h1>User ${this.routeContext?.params.id}</h1>
`;
}
}If you prefer imperative updates, the component may implement:
setRouteContext(context: RouteContext): voidComponents must expose either routeContext or setRouteContext(). The router
no longer injects legacy routeDetail / routeParams fields.
routeContext.slot identifies the slot this component was projected into, and
routeContext.branch contains the branch for that slot.
If a route declares children, the parent component must provide a slot for the
child branch:
<slot name="route-child"></slot>Or set a custom slot name on <router-view child-slot="content">.
Sibling child routes can target additional parent slots with slot:
{
path: "settings",
component: "settings-layout",
children: [
{ path: "profile", component: "settings-profile" },
{ path: "profile", slot: "sidebar", component: "profile-sidebar" },
],
}RouterChangeDetail.slotBranches exposes matched non-main slot branches.
Programmatic navigation:
router.push("/settings/profile");
router.replace("/settings");
router.push({ name: "user-detail", params: { id: "123" } });
const href = router.link({
name: "user-detail",
params: { id: "123" },
query: { tab: "activity" },
});
const attrs = router.linkAttributes(
{ name: "user-detail", params: { id: "123" } },
{ replace: true },
);
const match = router.resolveUrl("/users/123");
const namedMatch = router.resolveNamed("user-detail", {
params: { id: "123" },
});Named reverse routing supports literal segments, :param, :param(<pattern>),
*, and optional ? tokens such as :lang?/docs. Route tokens with + or *
modifiers are still rejected during router.link() generation.
Anchor navigation:
- Same-origin links inside the configured
basePathare intercepted. - Same-origin links outside
basePathfall back to the browser. - Both HTML and SVG
<a>elements are supported. - Add
data-router-replaceto an anchor to use replace-style navigation. - Use
router.linkAttributes(location, { replace: true })when creating replace-style links from JavaScript.
Leave guards run only for the suffix being unloaded. Shared parent routes are not re-checked when sibling child routes switch.
<router-view> supports:
.router: attach aRouterinstance directly.child-slot: customize the slot used for nested children.no-view-transition: disable page-level view transitions for that outlet.
Fallback views:
- If navigation resolves to
route-not-found,<router-view>renders a default 404 fallback instead of leaving the viewport blank. - If navigation throws
route-error,<router-view>renders a default error fallback. - You can override both with light DOM slots:
<router-view>
<not-found-page slot="404"></not-found-page>
<route-error-page slot="error"></route-error-page>
</router-view>Focus handling:
- After navigation, the view tries to focus
[data-route-focus]. - If none is found, it focuses the route viewport itself.
- The
router-viewroute-changeevent fires only after that outlet finishes DOM commit, scroll restore, and focus management. - The host element exposes
data-transition-direction="forward|backward|none"so CSS can distinguish push, pop, and replace-style transitions.
Scroll and hash behavior:
- Scroll restoration is tracked per history entry, not just by URL.
- Hash targets are resolved with
document.getElementById(). - If an anchor lives inside a component shadow tree, expose that
idon the host element in light DOM so hash scrolling stays O(1).
Recommended pattern:
<h1 data-route-focus tabindex="-1">User Detail</h1>Listen on either the Router instance or <router-view>:
router.addEventListener("route-change", (event) => {
console.log(event.detail.pathname);
});Available events:
route-change: successful route transition. OnRouter, it fires after the URL/current state is committed.event.detail.directionreportsforward|backward|none. On<router-view>, it fires later, after the rendered branch has mounted.route-tree-change: route definitions changed at runtime. Use this for menus, plugin registries, or permission-driven shells.event.detail.routeCountis cheap to read;event.detail.routesis a frozen read-only snapshot.route-error: guard, load, or commit failure.route-loading-start/route-loading-end: async route loading entered or finished.event.detail.pendingis the current in-flight loading count.route-not-found: no matching route for the current URL.
A compact playground covering the full feature set.
- index routes
- nested slot-based rendering
- lazy route loading with
route-loading-start/route-loading-end - guarded redirects and
beforeLeave - runtime
insertRoutes()/removeRoute() - explicit route context delivery
- route error / 404 fallback slots
- direction-aware
router-view[data-transition-direction]
Key files:
A full interactive demo with navigation bar, status panel, guarded lab route, and dynamic module loading. Run it with:
deno serve --port 8000 index.htmlKey files:
- This package is validated and published with the Deno toolchain. JSR publishes the TypeScript source directly.
- The source still imports
litthrough Deno's nativenpm:support. That is a Deno runtime feature, not annpm install/npm publishworkflow. - The router intentionally depends on the native
URLPatternAPI. Use a modern browser baseline or load aURLPatternpolyfill beforerouter.start(). - When the
Navigation APIis available, the router prefers it overpopstatefor same-document navigations. - Only one started
Routerinstance is supported per document at a time. - Route guards, lazy loaders, and route commits are async and race-aware.
- History-entry direction bookkeeping is bounded instead of growing without limit during very long-lived sessions.
router-viewkeeps a bounded scroll-position cache instead of allowing unbounded growth during long sessions.- For production shells, render explicit UI for
route-errorandroute-not-found.
The full exported API surface is documented in docs/api.md.