Navigation wrapper over Mithril's route-resolvers.
Live Demo — exercises all features: direction types, getIdentity, navContext, auth redirect, scroll restore, slide/fade/CSS animations, nested routes.
Wavelength Demo — mobile music app built on m-dot-nav,
demonstrating createMobileLayout with tab fade + drill-down slide, Now Playing sheet, mini player, and scroll restore.
Mithril's m.route() default routing implementation provides a straight-forward
and flexible approach for setting up an application's different page endpoints. However, navigation (i.e. functionality
to move between routes)
is left to the developer to implement and is typically solved using a
custom route resolver.
As discussed on Mithril's gitter chat a few times, it's
not uncommon to conflate application routing with application navigation. However, in my opinion, these concepts are
best considered as separate application concerns and be handled as such. An application's routes are its set of possible
endpoints (i.e. pages) whereas navigation is the mechanics of moving from one page to another. For example, the
customary mobile app page transition of sliding-out-left / sliding-in-right is a navigation concern. To that end,
m-dot-nav aims to aid the app developer with some utilities to assist with wiring together your application's routes.
# pin to a specific version tag (recommended)
npm install github:cavemansspa/m-dot-nav#v2.0.8import m, {createSlideLayout} from "m-dot-nav";
const Layout = createSlideLayout();
m.nav(document.body, "/home", {
"/home": Home,
"/about": About,
}, {
layoutComponent: Layout,
});Routes are defined exactly as with m.route() — components, route resolvers, or nested route objects all work.
Initializes navigation. Mirrors m.route() with an additional config argument.
config.layoutComponent— required. A Mithril component that receivestransitionStateas an attr andchildrenas the resolved route component.
Navigate programmatically. Mirrors m.route.set() with an additional anim argument for one-off animation overrides.
m.nav.setRoute("/about");
m.nav.setRoute("/item", {id: 42});
m.nav.setRoute("/login", null, {replace: true});
// one-off animation override
m.nav.setRoute("/modal", null, {}, (transitionState) => {
const {inbound, outbound} = transitionState.context;
// animate inbound["page"].dom in, call outbound["page"].resolver() when done
});Subscribe to navigation events. Currently supports onbeforeroutechange:
m.nav.addEventListener("onbeforeroutechange", (e) => {
const {transitionState, inbound, outbound} = e.detail;
console.log(transitionState.directionType);
});Your layout component receives attrs.transitionState on every render:
{
directionType, // see DirectionTypes below
rcState, // RouteChangeState for the current route
prevRcState, // RouteChangeState for the previous route (if applicable)
context, // shared object for layout ↔ animation communication
anim, // one-off animation function if passed to setRoute()
}import {DirectionTypes} from "m-dot-nav";
DirectionTypes.INITIAL // first route on load
DirectionTypes.FORWARD // navigating to a new route
DirectionTypes.BACK // returning to a previous route
DirectionTypes.SAME_ROUTE // same logical destination, nothing changed
DirectionTypes.SAME_ROUTE_CHANGE // same logical destination, params changed (see getIdentity)
DirectionTypes.EXISTING_ROUTE // returning to a non-adjacent history entry
DirectionTypes.REDRAW // plain Mithril redraw, no route changeA route can be defined as a component (with view) or a route resolver (with onmatch and/or render) — the
same as standard Mithril. m-dot-nav adds three optional properties that can appear on either form.
// Component — simplest form
"/home"
:
{
view({attrs})
{ ...
}
}
// Route resolver — for onmatch, render, or both
"/orders"
:
{
onmatch(args, requestedPath, route, navContext)
{ ...
}
,
render(vnode)
{ ...
}
,
}
// Either form can also include m-dot-nav additions:
"/products/:id"
:
{
getIdentity(onmatchParams), // control history matching
onbeforeroutechange(changeInfo), // notified when leaving this route
// then either view() or onmatch()/render() as above
onmatch(args, requestedPath, route, navContext)
{ ...
}
,
view({attrs})
{ ...
}
,
}onmatch receives a fourth argument, navContext, with direction-aware helpers:
"/orders"
:
{
onmatch(args, requestedPath, route, nav)
{
if (nav.isBack || nav.isExisting) {
restoreFromParams(args); // returning — URL has prior state
} else if (nav.isForward) {
resetAndLoad(args); // fresh navigation
} else if (nav.isSameRoute) {
// nothing changed — no-op
} else if (nav.isSameRouteChange) {
refreshData(args); // params changed, keep UI state
}
}
}navContext shape:
{
directionType, // full DirectionTypes value
isForward, // FORWARD or INITIAL
isBack, // BACK or EXISTING_ROUTE
isSameRoute, // SAME_ROUTE — nothing changed
isSameRouteChange, // SAME_ROUTE_CHANGE — same page, params changed
}Controls how routes are matched against the history stack. By default, identity includes route pattern + params + args — every unique URL is a distinct history entry.
Override getIdentity when you want different behavior:
// Ignore query params — filter changes stay on the same logical page
"/products/:id"
:
{
getIdentity({route, args})
{
return `${route}:${args.id}`; // sort/filter params ignored
}
,
onmatch(args, path, route, nav)
{
if (nav.isSameRouteChange) refreshData(args);
else resetAndLoad(args);
}
,
view({attrs})
{ ...
}
}
// Each wizard step is a distinct history entry
"/wizard/:step"
:
{
getIdentity({route, args})
{
return `${route}:${args.step}`;
}
,
view({attrs})
{ ...
}
}Called on the outbound route resolver just before the route changes:
"/list"
:
{
onbeforeroutechange({inbound, outbound, requestedPath})
{
// inbound.onmatchParams.route — route you're navigating TO
// outbound.onmatchParams.route — route you're leaving
saveScrollPosition();
}
,
view()
{ ...
}
}m-dot-nav ships three layout factories. Use them directly or as reference for building your own.
Horizontal slide — FORWARD slides in from the right, BACK from the left.
import {createSlideLayout} from "m-dot-nav";
const Layout = createSlideLayout({duration: 300}); // duration in ms
m.nav(document.body, "/home", routes, {layoutComponent: Layout});Combined layout for native mobile app navigation patterns — fades between top-level tab routes and slides for drill-down navigation within a tab. This matches the behavior of apps like Apple Music, Spotify, and most native iOS/Android apps.
import {createMobileLayout} from "m-dot-nav";
const Layout = createMobileLayout({
tabRoots: ["/library", "/browse", "/radio", "/search"],
duration: 280, // slide duration in ms (default: 300)
fadeDuration: 160, // fade duration for tab switches in ms (default: 180)
});
m.nav(document.body, "/library", routes, {layoutComponent: Layout});Tab switch (tab root → tab root) → fade — teleporting to a new section
Drill down / back (all other navigations) → horizontal slide
Tab root → tab root navigation fades. All other navigations slide horizontally. One-off anim overrides from setRoute
still take priority — useful for sheet/modal patterns:
// Tab switch — fades automatically
m.nav.setRoute("/browse");
// Drill-down — slides automatically
m.nav.setRoute("/library/artist/a1");
// Sheet — one-off override takes priority
m.nav.setRoute("/now-playing", null, {}, slideUpAnim);Fades the outbound page out. The inbound page is visible underneath.
import {createFadeLayout} from "m-dot-nav";
const Layout = createFadeLayout({duration: 200});import {createCssNavLayout} from "m-dot-nav";
const Layout = createCssNavLayout();Target the attributes in CSS:
[data-nav-anim="out"][data-direction="FORWARD"] {
animation: slideOutLeft 0.3s ease forwards;
}
[data-nav-anim="in"][data-direction="FORWARD"] {
animation: slideInRight 0.3s ease forwards;
}
[data-nav-anim="out"][data-direction="BACK"] {
animation: slideOutRight 0.3s ease forwards;
}
[data-nav-anim="in"][data-direction="BACK"] {
animation: slideInLeft 0.3s ease forwards;
}The primitive all layout factories are built on. Implement your own animation:
import {createNavLayout} from "m-dot-nav";
const Layout = createNavLayout({
animate(transitionState) {
const {outbound, inbound} = transitionState.context;
const outDom = outbound["page"].dom;
const inDom = inbound["page"].dom;
const resolver = outbound["page"].resolver;
// animate outDom out and inDom in, then call resolver()
// resolver() releases the outbound DOM from the page
}
});The animate function receives the full transitionState including directionType and anim (one-off override from
setRoute).
You can also write a layout component from scratch — m-dot-nav just requires that layoutComponent accepts
attrs.transitionState and renders children:
m.nav(document.body, "/home", routes, {
layoutComponent: {
view({attrs, children}) {
console.log(attrs.transitionState.directionType);
return m("div", children);
}
}
});When a protected route redirects to login, use { replace: true } on both legs so the login page never appears in the
history stack:
"/protected"
:
{
onmatch()
{
if (!isAuthed) {
m.nav.setRoute("/login", null, {replace: true});
// Return a never-resolving Promise to prevent Mithril from rendering
// this route. The Promise is immediately eligible for GC since nothing
// holds a reference to it — this is an idiomatic Mithril redirect pattern.
return new Promise(() => {
});
}
}
,
view()
{ ...
}
}
,
"/login"
:
{
view()
{
return m("button", {
onclick: () => {
isAuthed = true;
m.nav.setRoute("/protected", null, {replace: true});
}
}, "Log in");
}
}Result: back from /protected returns to wherever the user was before, skipping /login entirely.
Use onbeforeroutechange to save scroll position and navContext.isBack to restore it:
const List = (() => {
let scrollTop = 0;
let listDom = null;
return {
onbeforeroutechange() {
if (listDom) scrollTop = listDom.scrollTop;
},
onmatch(args, path, route, nav) {
if (!nav.isBack) scrollTop = 0; // fresh load — reset
},
view() {
return m("div", {
oncreate({dom}) {
listDom = dom;
if (scrollTop) dom.scrollTop = scrollTop;
},
onremove() {
listDom = null;
}
}, items);
}
};
})();m.nav(document.body, "/home", {
"/nested": {
"/a": {render: () => m(SubLayout, m("div", "/nested/a"))},
"/b": {render: () => m(SubLayout, m("div", "/nested/b"))},
}
}, {layoutComponent: Layout});flattenRoutes() flattens nested objects into top-level route keys (/nested/a, /nested/b). The sub-layout component
wraps the content returned from render().
m-dot-nav maintains its own history stack alongside the browser's. When you navigate to a route already in the stack,
it uses history.go(delta) for native back/forward traversal rather than pushing a new entry — keeping the browser
back/forward buttons aligned with in-app navigation.
A small helper attached to m for conditional class names:
m("div", {class: m.cls({active: isActive, disabled: isDisabled})})
// → "active", "disabled", or "active disabled"MIT