Settings app
A layout inside a layout, and data loaded before the page shows.
The settings routes are children of /settings, so the
settings layout stays while its Outlet changes. The
profile page keeps its tab in the query string, so it survives a reload
and the back button. The billing page loads its invoices in a route
loader.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Settings app - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<style>
.loading-bar {
height: 3px;
background: var(--i-color-primary);
animation: grow 0.6s ease-out infinite;
transform-origin: left;
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.loading-bar { animation: none; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const {
Component, application, html, router, Link, NavLink, Outlet, For, Show,
useQuery, useRouteData, useRouter, useIsNavigating,
} = Signy;
// A pretend API, 700ms away. The loader passes its AbortSignal along, as fetch() would take it.
const fetchInvoices = ({ signal }) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve([
{ id: "INV-1042", date: "2026-09-01", amount: 12 },
{ id: "INV-1093", date: "2026-10-01", amount: 12 },
]), 700);
signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
});
});
const TABS = [
["details", "Details"],
["photo", "Photo"],
];
class Home extends Component {
render() {
return html`
<h1>Dashboard</h1>
<p>Your settings live under their own layout.</p>
<${Link} to="/settings" role="button" class="primary">Open settings</${Link}>
`;
}
}
// The tab lives in the query string, so a reload or the back button keeps it
class Profile extends Component {
render() {
const query = useQuery(this);
const nav = useRouter(this);
const tab = () => query.value.tab || "details";
return html`
<h2>Profile</h2>
<div class="cluster" role="tablist">
${TABS.map(([id, label]) => html`
<button role="tab" class="small" aria-selected=${() => String(tab() === id)}
onClick=${() => nav.push({ path: "/settings", query: { tab: id } })}>${label}</button>
`)}
</div>
${Show({
when: () => tab() === "photo",
fallback: html`
<label for="display-name">Display name</label>
<input id="display-name" value="Ada Lovelace" />
`,
children: html`<p>Drop a photo here. This tab is <code>?tab=photo</code>.</p>`,
})}
`;
}
}
class Notifications extends Component {
render() {
return html`
<h2>Notifications</h2>
<label><input type="checkbox" role="switch" checked /> Email me about new replies</label>
<label><input type="checkbox" role="switch" /> Weekly summary</label>
`;
}
}
// The route loader ran before this page showed, so the data is already here
class Billing extends Component {
render() {
const invoices = useRouteData(this);
return html`
<h2>Billing</h2>
<table>
<thead><tr><th scope="col">Invoice</th><th scope="col">Date</th><th scope="col">Amount</th></tr></thead>
<tbody>
${For({
each: () => invoices.value ?? [],
keyBy: "id",
children: (invoice) => html`
<tr><td>${invoice.id}</td><td>${invoice.date}</td><td>${invoice.amount} EUR</td></tr>
`,
})}
</tbody>
</table>
`;
}
}
// Stays on screen for every settings page, only its Outlet changes
class SettingsLayout extends Component {
render() {
return html`
<h1>Settings</h1>
<div class="with-sidebar">
<nav class="sidenav" aria-label="Settings">
<ul>
<li><${NavLink} to="/settings">Profile</${NavLink}></li>
<li><${NavLink} to="/settings/notifications">Notifications</${NavLink}></li>
<li><${NavLink} to="/settings/billing">Billing</${NavLink}></li>
</ul>
</nav>
<section><${Outlet} /></section>
</div>
`;
}
}
class Layout extends Component {
render() {
const navigating = useIsNavigating(this);
return html`
${Show({ when: () => navigating.value, children: html`<div class="loading-bar" role="progressbar" aria-label="Loading"></div>` })}
<header class="navbar">
<nav class="container" aria-label="Main">
<ul><li><${Link} to="/"><strong>Acme</strong></${Link}></li></ul>
<ul><li><${Link} to="/settings">Settings</${Link}></li></ul>
</nav>
</header>
<main class="container"><${Outlet} /></main>
`;
}
}
const appRouter = router({
mode: "hash",
routes: [
{ path: "/", component: Home },
{
path: "/settings",
component: SettingsLayout,
children: [
{ path: "", component: Profile },
{ path: "notifications", component: Notifications },
{ path: "billing", component: Billing, loader: fetchInvoices },
],
},
{ path: "*", component: Home },
],
});
application(Layout).use(appRouter).mount("#app");
</script>
</body>
</html>
What it uses
- Nested routes
- NavLink, whose
aria-currentthe Iris CSS sidenav styles - useQuery() and useRouteData() with a loader
- useIsNavigating() for a progress bar