Skip to content

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

Open the page on its own

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