Navigation · Complete runnable example

Allowlisted hash router

Select existing document views from a short, explicit route list.

Live example

This preview runs in an isolated document. Clipboard writes and saved preferences may be blocked, and printing requires the standalone file. A dialog affects only the example. Reset reloads the original example state.

When to use it

Use this example to explore route state in a small static interface. It switches between pre-existing sections and supports direct fragment URLs without requiring server route configuration.

Try the important details

  1. Open Workbench and Notes, then use the browser's Back and Forward commands in the standalone document.
  2. Start at #/notes or enter an unknown fragment. Known routes select their section; unknown routes show a recovery link.
  3. Navigate using the keyboard and check active-link state, document title, and focus on the selected heading.

How it works

  • Only overview, workbench, and notes are accepted. Routes reference existing section IDs rather than constructing HTML or fetching URLs.
  • The hashchange listener handles navigation history. Re-selecting the current route still restores heading focus.
  • Unknown fragments are bounded and displayed with textContent. Without JavaScript, the baseline links target fully visible sections.

The complete source

The preview, copied text, and downloaded file use this same document.

HTML

<!doctype html>
<!--
MIT License

Copyright (c) 2026 MRHTML

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; form-action 'none'; object-src 'none'; base-uri 'none'"
    />
    <title>MRHTML | Three views. One document.</title>
    <style>
      :root {
        font-family: Arial, Helvetica, sans-serif;
        color-scheme: light;
        --ink: #192f34;
        --muted: #56666a;
        --paper: #f6f5f0;
        --line: #cdd5d0;
        --accent: #186354;
        --soft: #e7efe5;
        --mono: Consolas, 'Liberation Mono', monospace;
      }
      * {
        box-sizing: border-box;
      }
      html {
        scroll-padding-top: 90px;
      }
      body {
        margin: 0;
        background: var(--paper);
        color: var(--ink);
        line-height: 1.6;
      }
      h1,
      h2,
      h3,
      p {
        margin: 0;
      }
      h1,
      h2,
      h3 {
        font-weight: 500;
        letter-spacing: -0.045em;
        line-height: 1.1;
      }
      h1 {
        font-size: clamp(38px, 5.5vw, 65px);
      }
      h2 {
        font-size: 29px;
      }
      h3 {
        font-size: 21px;
      }
      p {
        overflow-wrap: anywhere;
      }
      a {
        color: var(--accent);
        text-underline-offset: 4px;
      }
      button,
      select {
        font: inherit;
        color: inherit;
      }
      button {
        cursor: pointer;
      }
      button,
      a,
      summary {
        touch-action: manipulation;
      }
      button:focus-visible,
      a:focus-visible,
      select:focus-visible,
      [tabindex]:focus-visible,
      summary:focus-visible {
        outline: 3px solid #a94823;
        outline-offset: 4px;
      }
      [hidden] {
        display: none !important;
      }
      .wrap {
        width: min(1050px, calc(100% - 64px));
        margin-inline: auto;
      }
      .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 20px;
        padding: 28px 0;
        border-bottom: 1px solid var(--line);
      }
      .brand {
        font-size: 15px;
        font-weight: 700;
        display: flex;
        align-items: center;
        gap: 10px;
        letter-spacing: -0.5px;
      }
      .mark {
        display: grid;
        place-items: center;
        width: 28px;
        height: 28px;
        background: var(--accent);
        color: #fff;
        font-weight: bold;
        font-size: 18px;
      }
      .header > p,
      .eyebrow,
      .small {
        font: 10px/1.7 var(--mono);
        letter-spacing: 0.03em;
      }
      .header > p {
        color: var(--muted);
      }
      main {
        padding-block: 43px 50px;
      }
      .intro {
        margin-bottom: 30px;
        max-width: 720px;
      }
      .eyebrow {
        color: var(--accent);
        margin-bottom: 13px;
      }
      .intro > p:last-child {
        font-size: 15px;
        max-width: 570px;
        color: var(--muted);
        margin-top: 18px;
      }
      .surface {
        background: #fff;
        border: 1px solid var(--line);
        border-radius: 14px;
        box-shadow: 0 12px 32px #263e3010;
      }
      .button {
        border: 1px solid var(--accent);
        background: var(--accent);
        color: #fff;
        padding: 10px 16px;
        min-height: 43px;
        border-radius: 7px;
        font-size: 13px;
        font-weight: 600;
      }
      .button.secondary {
        background: #fff;
        color: var(--accent);
        border-color: #a9bbb0;
      }
      .button[aria-disabled='true'],
      button:disabled {
        background: #edf0e9;
        color: #5d695f;
        border-color: #c9d1c4;
        cursor: default;
      }
      .status {
        font-size: 12px;
        color: var(--muted);
        min-height: 22px;
      }
      .note {
        font-size: 12px;
        color: var(--muted);
        margin-top: 20px;
        max-width: 75ch;
      }
      .footer {
        display: flex;
        justify-content: space-between;
        gap: 20px;
        padding-block: 22px;
        border-top: 1px solid var(--line);
        font-size: 10px;
        color: var(--muted);
      }
      .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
      noscript p {
        padding: 15px;
        background: #e8ece2;
        border-left: 3px solid var(--accent);
        font-size: 13px;
        margin-bottom: 20px;
      }
      code,
      kbd {
        font: 12px var(--mono);
      }
      kbd {
        border: 1px solid #bcc7bd;
        border-radius: 4px;
        padding: 2px 5px;
        background: #fff;
      }
      button:disabled:focus {
        outline: none;
      }
      @media (max-width: 600px) {
        .wrap {
          width: calc(100% - 36px);
        }
        .header {
          padding-block: 22px;
        }
        .header > p {
          font-size: 8px;
          max-width: 105px;
          text-align: right;
        }
        .brand {
          font-size: 13px;
          gap: 8px;
        }
        main {
          padding-block: 30px 35px;
        }
        .intro {
          margin-bottom: 26px;
        }
        .intro > p:last-child {
          font-size: 14px;
        }
        .eyebrow {
          font-size: 9px;
        }
        h1 {
          font-size: 40px;
        }
        .footer {
          flex-wrap: wrap;
          font-size: 9px;
        }
      }
      @media (max-width: 350px) {
        h1 {
          font-size: 35px;
        }
        .button {
          padding: 10px 12px;
          font-size: 12px;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        *,
        *::before,
        *::after {
          animation: none !important;
          transition: none !important;
          scroll-behavior: auto !important;
        }
      }

      .notebook {
        overflow: hidden;
      }
      .route-nav {
        display: flex;
        gap: 5px;
        padding: 15px 20px;
        border-bottom: 1px solid var(--line);
        background: #e9eee3;
      }
      .route-nav a {
        padding: 12px 19px;
        border-radius: 6px;
        text-decoration: none;
        font-size: 13px;
        color: #46623e;
      }
      .route-nav a[aria-current='page'] {
        background: #fff;
        color: #1b422e;
        box-shadow: 0 2px 8px #263b2210;
        font-weight: bold;
      }
      .route-view {
        padding: 42px;
        display: grid;
        grid-template-columns: 1.2fr 1fr;
        gap: 42px;
        align-items: center;
        min-height: 385px;
      }
      .route-view + .route-view {
        border-top: 1px solid var(--line);
      }
      .route-view h2 {
        font-size: 43px;
        line-height: 1.05;
      }
      .route-view p:not(.eyebrow) {
        font-size: 14px;
        color: var(--muted);
        margin-top: 20px;
        max-width: 42ch;
      }
      .route-link {
        display: inline-block;
        margin-top: 23px;
        font-size: 13px;
      }
      .notebook-art {
        height: 275px;
        background: #e6dfc8;
        position: relative;
        border-radius: 7px;
        display: grid;
        place-items: center;
        overflow: hidden;
      }
      .notebook-art i {
        position: absolute;
        width: 165px;
        height: 220px;
        transform: rotate(13deg);
        background: #abb985;
        border: 1px solid #7d8f5d;
      }
      .notebook-art i:nth-child(2) {
        transform: rotate(-9deg);
        background: #f5f5e9;
        left: 50px;
      }
      .notebook-art > span {
        position: relative;
        font-size: 51px;
        line-height: 0.92;
        letter-spacing: -3px;
        transform: rotate(-9deg);
        color: #3b5b2f;
      }
      .route-status {
        margin-top: 20px;
      }
      .route-view ul {
        padding-left: 20px;
        font-size: 13px;
        color: var(--muted);
        line-height: 2;
        margin-top: 20px;
      }
      .workbench-note {
        background: #e4ecdc;
        padding: 27px;
        min-height: 260px;
        border-top: 4px solid #708756;
      }
      .workbench-note p {
        font-size: 30px !important;
        line-height: 1.25;
        letter-spacing: -1px;
        color: #36562e !important;
      }
      .workbench-note strong {
        font-weight: 500;
      }
      .notes-art {
        background: #f0e2c5;
        padding: 30px;
        min-height: 265px;
        display: flex;
        flex-direction: column;
        gap: 19px;
        transform: rotate(2deg);
      }
      .notes-art span:first-child {
        font: 40px var(--mono);
        color: #775727;
      }
      .notes-art i {
        height: 1px;
        background: #ae946b;
      }
      .notes-art span:last-child {
        font-size: 12px;
        color: #775727;
        margin-top: 8px;
      }
      .not-found {
        grid-template-columns: 1fr;
      }
      .not-found #unknown-route {
        font-family: var(--mono);
        font-size: 12px;
        overflow-wrap: anywhere;
      }
      @media (max-width: 640px) {
        .route-nav {
          padding: 12px;
          gap: 3px;
          justify-content: space-between;
        }
        .route-nav a {
          padding: 11px 12px;
          font-size: 12px;
        }
        .route-view {
          padding: 27px;
          grid-template-columns: 1fr;
          gap: 30px;
        }
        .route-view h2 {
          font-size: 37px;
        }
        .route-view p:not(.eyebrow) {
          font-size: 13px;
        }
        .notebook-art {
          height: 250px;
        }
        .notebook-art i:nth-child(2) {
          left: auto;
        }
        .workbench-note {
          min-height: 220px;
        }
        .notes-art {
          min-height: 220px;
        }
        .route-view ul {
          font-size: 12px;
        }
      }
      @media (max-width: 350px) {
        .route-nav a {
          font-size: 11px;
          padding-inline: 10px;
        }
        .route-view {
          padding: 23px;
        }
        .route-view h2 {
          font-size: 33px;
        }
      }
    </style>
  </head>
  <body>
    <header class="header wrap">
      <span class="brand"
        ><span class="mark" aria-hidden="true">m.</span>MRHTML / Workshop</span
      >
      <p>SMALL PATTERNS.<br />CONSIDERED DETAILS.</p>
    </header>
    <main class="wrap">
      <div class="intro">
        <p class="eyebrow">06 / ALLOWLISTED HASH ROUTER</p>
        <h1>Three views. One document.</h1>
        <p>
          Explore a fictional workshop notebook. Routes select existing
          sections, keep browser history, and leave unknown paths in a clear
          not-found state.
        </p>
      </div>
      <noscript
        ><p>
          All three sections are shown and their links work as ordinary anchors.
          JavaScript adds route selection and history-aware views.
        </p></noscript
      >
      <div class="surface notebook">
        <nav class="route-nav" aria-label="Notebook views">
          <a href="#overview" data-route="overview">Overview</a
          ><a href="#workbench" data-route="workbench">Workbench</a
          ><a href="#notes" data-route="notes">Notes</a>
        </nav>
        <section
          class="route-view"
          id="overview"
          aria-labelledby="heading-overview"
        >
          <div>
            <p class="eyebrow">THE NOTEBOOK / 01</p>
            <h2 id="heading-overview" tabindex="-1">
              A place for<br />unfinished ideas.
            </h2>
            <p>
              Keep the question, the experiment, and the next step together.
              This notebook is sample content, ready to explore.
            </p>
            <a href="#workbench" data-route="workbench" class="route-link"
              >Open the workbench →</a
            >
          </div>
          <div class="notebook-art" aria-hidden="true">
            <i></i><i></i><span>start<br />small.</span>
          </div>
        </section>
        <section
          class="route-view"
          id="workbench"
          aria-labelledby="heading-workbench"
        >
          <div>
            <p class="eyebrow">THE NOTEBOOK / 02</p>
            <h2 id="heading-workbench" tabindex="-1">
              Make one thing<br />clearer.
            </h2>
            <p>
              A compact workbench for the next version. These notes describe a
              sample process; no tasks are submitted or synchronized.
            </p>
            <ul>
              <li>Choose one question to answer.</li>
              <li>Build a complete path through the idea.</li>
              <li>Write down what surprised you.</li>
            </ul>
          </div>
          <div class="workbench-note">
            <span class="small">CURRENT QUESTION</span>
            <p>
              What would make<br />the next step<br /><strong>obvious?</strong>
            </p>
          </div>
        </section>
        <section class="route-view" id="notes" aria-labelledby="heading-notes">
          <div>
            <p class="eyebrow">THE NOTEBOOK / 03</p>
            <h2 id="heading-notes" tabindex="-1">
              Leave a note<br />for later.
            </h2>
            <p>
              Good documentation keeps a choice understandable after the
              conversation has ended.
            </p>
            <ul>
              <li>What changed and why?</li>
              <li>What remains outside the scope?</li>
              <li>What evidence would change your mind?</li>
            </ul>
          </div>
          <div class="notes-art" aria-hidden="true">
            <span>01</span><i></i><i></i><i></i
            ><span>Keep the reason close.</span>
          </div>
        </section>
        <section
          class="route-view not-found"
          id="not-found"
          aria-labelledby="heading-not-found"
          hidden
        >
          <div>
            <p class="eyebrow">THIS VIEW IS NOT IN THE NOTEBOOK</p>
            <h2 id="heading-not-found" tabindex="-1">Page not found.</h2>
            <p id="unknown-route"></p>
            <a class="route-link" href="#overview" data-route="overview"
              >Return to overview →</a
            >
          </div>
        </section>
      </div>
      <p
        id="route-status"
        class="status route-status"
        role="status"
        aria-live="polite"
        aria-atomic="true"
      >
        All notebook sections are available below.
      </p>
      <p class="note">
        The supported enhanced routes are <code>#/overview</code>,
        <code>#/workbench</code>, and <code>#/notes</code>. This is a local view
        switcher, not a server router.
      </p>
    </main>
    <footer class="footer wrap">
      <span>MRHTML / Original runnable example</span
      ><span>Local content. No network required.</span>
    </footer>
    <script>
      (() => {
        'use strict';

        const keys = ['overview', 'workbench', 'notes'];
        const views = keys.map((key) => document.getElementById(key));
        const links = [...document.querySelectorAll('[data-route]')];
        const titles = {
          overview: 'Overview',
          workbench: 'Workbench',
          notes: 'Notes',
        };
        function render(focus) {
          const raw = location.hash;
          const key =
            raw === ''
              ? 'overview'
              : raw.startsWith('#/')
                ? raw.slice(2)
                : raw.slice(1);
          const known = keys.includes(key);
          views.forEach((view) => {
            view.hidden = !known || view.id !== key;
          });
          const missing = document.querySelector('#not-found');
          missing.hidden = known;
          document.querySelector('#unknown-route').textContent = known
            ? ''
            : `Unknown route: ${raw.slice(0, 140)}${raw.length > 140 ? '…' : ''}`;
          links.forEach((link) => {
            if (known && link.dataset.route === key)
              link.setAttribute('aria-current', 'page');
            else link.removeAttribute('aria-current');
          });
          document.title = `MRHTML | ${known ? titles[key] : 'Page not found'}`;
          document.querySelector('#route-status').textContent = known
            ? `${titles[key]} view. Only this document changed.`
            : 'Route not found. Choose a notebook view.';
          if (focus)
            (known ? document.getElementById(key) : missing)
              .querySelector('h2')
              .focus({ preventScroll: false });
        }
        links.forEach((link) =>
          link.addEventListener('click', (event) => {
            event.preventDefault();
            const key = link.dataset.route;
            if (!keys.includes(key)) return;
            const hash = '#/' + key;
            if (location.hash === hash) render(true);
            else location.hash = hash;
          }),
        );
        window.addEventListener('hashchange', () => render(true));
        render(Boolean(location.hash));
      })();
    </script>
  </body>
</html>