/nav-tree-demo— ONE tree drives both the sidebar and the routes (recommended starting point)./test/links/sidebar— compares all parent-active patterns side-by-side./test/links— link-action fundamentals.
use:link — SPA navigation
The use:link action turns a regular <a> into an SPA
link: it intercepts the click, prevents a full page reload, and calls push() internally. Modifier keys (Ctrl/Cmd-click, middle-click) and target="_blank" are respected — the browser still handles those.
For programmatic navigation, named routes, and the array/object call forms, see Named Routes and Programmatic Navigation.
use:active — adding a CSS class to the matching link
use:active adds a CSS class to the element whenever its
configured path matches the current location. With no argument, the path
defaults to the link's href and the class is active.
use:active listens to hashchange and popstate directly on window. It is NOT a reactive store — you can't $derived off it. Read location() if you need that in JS.Explicit path — string prefix or regex
Pass an explicit path to match something other than the link's
own href. A string is run through regexparam, so you get the same :param and * syntax used in route definitions.
Wildcard quirk: /docs/* matches descendants only
/foo/* does NOT match the bare path /foo. It only matches descendants like /foo/anything.regexparam compiles /foo/* to /^\/foo\/(.*)\/?$/i — the trailing slash after foo is mandatory. So a sidebar parent using use:active={'/foo/*'} goes dark the moment the user
lands on the bare /foo index page.
Use this deliberately when you want a link UNHIGHLIGHTED on the index page and only lit on descendants — that's a legitimate UX choice. For the common "parent stays lit on its own index AND on every child" case, see the next section.
Branch matching — bare path + descendants
For sidebar parents that should stay highlighted on /docs AND every child,
you have three equivalent options.
1. subtree option (recommended)
No path string, no regex. The action reads the link's href and
internally registers both an exact match AND a /docs/* descendants pattern. Best fit when the href is a variable in a generated nav.
2. Regex form
Useful when you can't or don't want to depend on the link's href.
3. Stack two use:active calls (long form of subtree)
The default action catches the bare path; the prefix action catches
descendants. Both add the same active class. Library aggregates
per (node, className) so the two actions cooperate instead of
fighting.
All three yield:
/docs→ active/docs/guide→ active/docs/api/intro→ active/about→ not active
Sidebar with submenu
Parent stays active on its own index page AND when any child route is open; children only highlight on their exact path.
Behavior across URLs:
| Current URL | Parent "Users" | Submenu "All users" | Submenu "Create user" |
|---|---|---|---|
/users | active | active | — |
/users/new | active | — | active |
/users/123 | active | — | — |
/ | — | — | — |
Two-class pattern — distinguish "really active" from "parent of active"
Style the link that exactly matches the current URL differently from a
parent whose descendant is active. The cleanest way is the subtree + subtreeClassName pair — one action
call covers both cases.
Long form: stacked use:active calls (equivalent)
Behavior across URLs:
| Current URL | Parent "Users" | Child "All users" | Child "Create" | Child "User 123" |
|---|---|---|---|---|
/users | link-active (red) | link-active | — | — |
/users/new | sublink-active (orange) | — | link-active | — |
/users/123 | sublink-active | — | — | link-active |
/about | — | — | — | — |
/users;
the /users/* action only matches descendants. The two
classes never collide, so the parent is unambiguously either "really active"
or "branch active" — never both.Three-level menu
Repeat the pattern at each ancestor depth.
On /admin/users/123: grandparent and parent both get sublink-active, the leaf gets link-active.
Generating nav from a route tree
Once you can decide "parent or leaf?" at render time, you can drive
the entire sidebar from a single data structure — the same one you
pass to createHierarchy() for route registration.
A reusable NavLink helper
Wraps use:link + use:active so the menu
walker stays small. Pass subtree={true} for parent
nodes; default behavior for leaves.
Walk the tree to render the menu
Same tree drives the routes too
/nav-tree-demo — see example/src/routes/NavTreeDemo.svelte and example/src/routes/nav-tree.js for the full source.Permission-filtered nav (helpers/nav-tree)
The filterByPermissions(tree) helper from @keenmate/svelte-spa-router/helpers/nav-tree walks a
route tree and either hides nodes the current user can't reach (default)
or keeps them in place with a _forbidden flag for
styled disabled rendering.
Two modes
NavTree node schema
Naming note: the boolean fields use bare HTML-attribute names
(hidden, disabled) to align with the KeenMate
web-components convention. The helper predicate isNodeHidden(node) keeps its is* prefix because it's a function, not a field.
hidden — static boolean or reactive getter
Either form works. The getter is called every filter pass, so anything
it reads — environment variables, $state, feature flags —
participates in the reactive update chain automatically.
disabled — "coming soon" placeholder
disabled: true marks a node as a product-level placeholder —
"coming soon", "in private beta", "Q3 roadmap". It renders forbidden in both filter modes (it never simply disappears in hide mode like hidden does), because the intent is to advertise the item, not gate
it on user permissions. Ancestor permission denial still cascades — you can't see
a placeholder in a section you can't enter.
Pair with the new disabledClassName filter option to style
placeholders distinctly from permission-denied items (amber "unavailable" vs.
red "forbidden"). When a node is both disabled AND permission-denied
(rare combo), disabledClassName wins — the product-level signal is
the more permanent one.
Hierarchical permission inheritance
When inheritPermissions: true (default), child accessibility
requires every ancestor's permission check to also pass. The filter
chains sequential hasPermission() calls — one per level —
which matches how the router's own hierarchical-mode pipeline checks
routes. Set inheritPermissions: false if your nav structure
doesn't mirror an access hierarchy.
Behavior matrix
| Node state | Mode 'hide' | Mode 'disable' |
|---|---|---|
hidden: true or getter returns true | gone | gone (same — explicit "never in nav") |
| User has access | shown | shown |
| User lacks access | gone | shown + _forbidden: true + .forbidden class |
disabled: true (product-level placeholder) | shown + _forbidden: true + .unavailable class* | shown + _forbidden: true + .unavailable class* |
| All visible children forbidden | parent gone | parent forbidden (cascading; keeps .forbidden) |
* When disabledClassName is set on the filter options. Defaults
to forbiddenClassName if omitted (backward compatible).
Reactivity
filterByPermissions() calls hasPermission() internally, which reads through the reactive user state. Calling
the filter inside a $derived makes the entire sidebar
update live when:
setCurrentUser()fires (login, logout, role change, websocket permission update)- Any
hiddengetter reads$statethat mutates - Any inherited reactive state changes
No subscription wiring, no manual invalidation — same model as the rest of the runes-based router.
NavLink — forbidden rendering
When the filter returns a node with _forbidden: true, NavLink renders a non-interactive <span> with the
configured class and aria-disabled="true" instead of an <a>. No use:link, no navigation, no use:active.
/nav-tree-demo ships with a user toggle (Donna with limited permissions ↔ Audrey
with full access) and a mode toggle (hide ↔ disable) — flip both
and watch the sidebar re-render live, with no page reload.inactiveClassName — class while NOT matching
The options form also accepts inactiveClassName for a class
that's applied while the pattern does NOT match. Useful for muted/disabled
visual states.
When multiple use:active actions share the same inactiveClassName, the class is present only when NO
controlling entry matches — the element is treated as "fully inactive."
Pitfalls
- Path strings must start with
/or*. Anything else throws — internal locations always start with/, so a path like'foo'could never match. - The pattern is compiled once at mount. Changing
opts.pathreactively won't re-parse — Svelte would need to tear the element down and remount it. use:activeis browser-only. The module has atypeof window !== 'undefined'guard for SSR.- Don't use
use:linkon external URLs (use a plain<a>). The action would attempt SPA navigation and break.