Accessibility pattern · Layout and structure

Focus after a page change

When a single-page app swaps its content, the browser does none of what a page load does: the title, focus, the announcement, scroll and Back are all the app's job. Each change of page here sets the title and the current link, moves focus to the new heading, says the page has loaded once its content is ready, and keeps a scroll position for every step back in the history.

WCAG criteria
7
Keyboard rules
6
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Page title: Home · Panna Books

Address: pannabooks.in

Panna Books

Home

Welcome back, Asha. Here is where your books are.

On its way

Order PW-4821 arrives on Thursday, 8 October.

See your orders

Delivering to

Flat 4B, Lotus Residency, Baner, Pune 411045

Change delivery settings

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
TabMoves through the window's Back and Forward buttons, the app's links, then the open page; the pages that are not shown are skipped.
EnterOn a link in the app, opens that page: the address, the title and the current link change, and focus moves to the new page's heading.
AltArrow Left or AltArrow RightThe browser's own Back and Forward (Cmd+[ and Cmd+] on a Mac): returns to the previous or next page of the app, scrolled where you left it, with focus on its heading.
Enter or SpaceOn the window's Back or Forward button, does the same as the browser's own; each is switched off when there is nowhere to go.
CtrlEnterOn a link, opens that page in a new browser tab: the app leaves Ctrl, Cmd, Shift and middle clicks to the browser.
Arrow Down or Page DownWith focus on the heading, scrolls the app's page; Tab also brings each order into view.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Tab reaches the app's linksDemo app, navigation, list, 3 items. Home, link, current page
A link opens SettingsSettings, heading level 3. Then, politely: Settings page loaded
A link opens Orders, whose list takes a momentOrders, heading level 3. When the list has arrived: Orders page loaded
Back returns to HomeHome, heading level 3. Then: Home page loaded
Focus reaches the window's Back buttonBrowser, group. Back, button

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-route" data-ap-route-change>
  <div class="ap-route__window">
    <div class="ap-route__chrome">
      <p class="ap-route__tab">
        <span class="ap-route__favicon" aria-hidden="true"></span>
        <span class="ap-route__vh">Page title:</span>
        <span data-ap-route-title="home">Home · Panna Books</span>
        <span data-ap-route-title="orders" hidden>Orders · Panna Books</span>
        <span data-ap-route-title="settings" hidden>Settings · Panna Books</span>
      </p>
      <div class="ap-route__toolbar" role="group" aria-label="Browser">
        <button type="button" class="ap-route__hist" data-ap-route-back aria-label="Back" disabled><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg></button>
        <button type="button" class="ap-route__hist" data-ap-route-forward aria-label="Forward" disabled><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg></button>
        <p class="ap-route__address">
          <svg class="ap-route__lock" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
          <span class="ap-route__vh">Address:</span>
          <span translate="no" data-ap-route-url>pannabooks.in</span>
        </p>
      </div>
    </div>

    <div class="ap-route__app">
      <header class="ap-route__bar">
        <p class="ap-route__brand">
          <span class="ap-route__logo" aria-hidden="true"><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 6.5C10 5 7 4.6 4 5v13c3-.4 6 .1 8 1.5 2-1.4 5-1.9 8-1.5V5c-3-.4-6 0-8 1.5Z"/><path d="M12 6.5V19"/></svg></span>
          Panna Books
        </p>
        <nav class="ap-route__nav" aria-label="Demo app">
          <ul>
            <li><a class="ap-route__link" href="#route-home" aria-current="page"><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.5 10.5 12 3.5l8.5 7"/><path d="M5.5 9v11h13V9"/><path d="M10 20v-5.5h4V20"/></svg>Home</a></li>
            <li><a class="ap-route__link" href="#route-orders"><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20.5 7.5 12 3 3.5 7.5v9L12 21l8.5-4.5Z"/><path d="m3.5 7.5 8.5 4.5 8.5-4.5"/><path d="M12 12v9"/></svg>Orders</a></li>
            <li><a class="ap-route__link" href="#route-settings"><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg>Settings</a></li>
          </ul>
        </nav>
      </header>

      <div class="ap-route__viewport">
        <div class="ap-route__progress" aria-hidden="true"></div>
        <div class="ap-route__page" data-ap-route-page>
          <div class="ap-route__view" data-ap-route-view="home">
            <div class="ap-route__top"><h3 class="ap-route__h" tabindex="-1">Home</h3></div>
            <p class="ap-route__lead">Welcome back, Asha. Here is where your books are.</p>
            <div class="ap-route__cards">
              <div class="ap-route__card">
                <span class="ap-route__badge" aria-hidden="true"><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 6.5h11V16H3Z"/><path d="M14 9.5h3.8l3.2 3.3V16h-7"/><circle cx="7" cy="17.5" r="1.8"/><circle cx="17" cy="17.5" r="1.8"/></svg></span>
                <h4 class="ap-route__card-name">On its way</h4>
                <p>Order PW-4821 arrives on Thursday, 8 October.</p>
                <a class="ap-route__more" href="#route-orders">See your orders</a>
              </div>
              <div class="ap-route__card">
                <span class="ap-route__badge" aria-hidden="true"><svg class="ap-route__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-6.5-5.8-6.5-11a6.5 6.5 0 0 1 13 0c0 5.2-6.5 11-6.5 11Z"/><circle cx="12" cy="10" r="2.4"/></svg></span>
                <h4 class="ap-route__card-name">Delivering to</h4>
                <p>Flat 4B, Lotus Residency, Baner, Pune 411045</p>
                <a class="ap-route__more" href="#route-settings">Change delivery settings</a>
              </div>
            </div>
          </div>

          <div class="ap-route__view" data-ap-route-view="orders" hidden>
            <div class="ap-route__top"><h3 class="ap-route__h" tabindex="-1">Orders</h3></div>
            <p class="ap-route__lead">Everything you ordered in the last three months. Open an order to see its books.</p>
            <div class="ap-route__loading" data-ap-route-loading hidden>
              <p class="ap-route__wait"><span class="ap-route__spinner" aria-hidden="true"></span>Loading your orders…</p>
              <div class="ap-route__skel" aria-hidden="true"><span></span><span></span><span></span></div>
            </div>
            <ul class="ap-route__orders" data-ap-route-list>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4821</span>
                      <span class="ap-route__order-meta">Placed 2 October · ₹1,148</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--way"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 6.5h11V16H3Z"/><path d="M14 9.5h3.8l3.2 3.3V16h-7"/><circle cx="7" cy="17.5" r="1.8"/><circle cx="17" cy="17.5" r="1.8"/></svg>On the way</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">The Palace of Illusions</span> <span class="ap-route__format">Paperback</span></li>
                    <li><span translate="no">Train to Pakistan</span> <span class="ap-route__format">Paperback</span></li>
                  </ul>
                </details>
              </li>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4796</span>
                      <span class="ap-route__order-meta">Placed 21 September · ₹499</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--done"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Delivered</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">The God of Small Things</span> <span class="ap-route__format">Paperback</span></li>
                  </ul>
                </details>
              </li>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4760</span>
                      <span class="ap-route__order-meta">Placed 8 September · ₹758</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--done"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Delivered</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">Malgudi Days</span> <span class="ap-route__format">Paperback</span></li>
                    <li><span translate="no">Swami and Friends</span> <span class="ap-route__format">Paperback</span></li>
                  </ul>
                </details>
              </li>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4712</span>
                      <span class="ap-route__order-meta">Placed 26 August · ₹350</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--done"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Delivered</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">Gitanjali</span> <span class="ap-route__format">Hardcover</span></li>
                  </ul>
                </details>
              </li>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4688</span>
                      <span class="ap-route__order-meta">Placed 14 August · ₹1,290</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--back"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H12"/></svg>Returned</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">A Suitable Boy</span> <span class="ap-route__format">Hardcover</span></li>
                  </ul>
                </details>
              </li>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4630</span>
                      <span class="ap-route__order-meta">Placed 30 July · ₹620</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--done"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Delivered</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">Wings of Fire</span> <span class="ap-route__format">Paperback</span></li>
                  </ul>
                </details>
              </li>
              <li>
                <details class="ap-route__order">
                  <summary>
                    <span class="ap-route__order-main">
                      <span class="ap-route__order-id">Order PW-4597</span>
                      <span class="ap-route__order-meta">Placed 12 July · ₹899</span>
                    </span>
                    <span class="ap-route__chip ap-route__chip--done"><svg class="ap-route__chip-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Delivered</span>
                    <svg class="ap-route__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
                  </summary>
                  <ul class="ap-route__items">
                    <li><span translate="no">The White Tiger</span> <span class="ap-route__format">Paperback</span></li>
                    <li><span translate="no">Em and the Big Hoom</span> <span class="ap-route__format">Paperback</span></li>
                  </ul>
                </details>
              </li>
            </ul>
          </div>

          <div class="ap-route__view" data-ap-route-view="settings" hidden>
            <div class="ap-route__top"><h3 class="ap-route__h" tabindex="-1">Settings</h3></div>
            <p class="ap-route__lead">Choose how we keep in touch, and where your books go.</p>
            <fieldset class="ap-route__group">
              <legend class="ap-route__legend">Notifications</legend>
              <label class="ap-route__setting">
                <span>Email me when an order ships</span>
                <input class="ap-route__switch" type="checkbox" role="switch" checked />
              </label>
              <label class="ap-route__setting">
                <span>Text me on the delivery day</span>
                <input class="ap-route__switch" type="checkbox" role="switch" />
              </label>
              <label class="ap-route__setting">
                <span>Monthly reading picks</span>
                <input class="ap-route__switch" type="checkbox" role="switch" />
              </label>
            </fieldset>
            <div class="ap-route__group">
              <h4 class="ap-route__legend">Delivery address</h4>
              <p class="ap-route__person">Asha Rao</p>
              <p class="ap-route__address-text">Flat 4B, Lotus Residency, Baner, Pune 411045</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>

  <p class="ap-route__vh" role="status" data-ap-route-live></p>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    The app's links are a named nav list and the current one carries aria-current="page", so which page is open is exposed, not only drawn.

  • 2.1.1 Keyboard Level A

    Links, the browser's Back and Forward keys and the window's buttons all work from the keyboard; modified clicks are left to the browser.

  • 2.4.2 Page Titled Level A

    Every page of the app sets its own document title, shown in the window's tab, so the tab, the history list and a screen reader all name the page you are on.

  • 2.4.3 Focus Order Level A

    Focus moves to the new page's heading, so the next Tab continues into the new content instead of from a link in the old place or from the top of the document.

  • 2.4.8 Location Level AAA

    The title, the heading and the current link all give the same name, so people always know where they are in the app.

  • 2.4.11 Focus Not Obscured (Minimum) Level AA

    Each page's heading sticks to the top of its scroller, so when Back puts a scroll position back, the focused heading is still in view.

  • 4.1.3 Status Messages Level AA

    "Orders page loaded" is a polite status message, sent once the content is ready; it is read without moving focus anywhere.

Usage

When to use it

Use it

  • Single-page apps and client-side routers, where following a link replaces the content without loading a new document.
  • Parts of an app that deserve their own address: an account, a dashboard, a settings area.

Use something else

  • Switching panels within one page: use tabs, which do not change the address or the history.
  • Content that updates in place, like a filtered list: keep focus where it is and announce the result with a status message.
  • Sites that can use real page loads: the browser then sets the title, resets focus and keeps scroll and history for you.

Common failures

How it usually goes wrong

  • A silent change of page

    The content swaps, but focus stays on the link and nothing is announced. A screen reader user hears nothing and cannot tell that anything happened.

  • One title for every page

    If document.title never changes, the tab, the history list and the page title a screen reader reads all name the wrong page.

  • Focus lost with the old page

    Removing or hiding the view that held focus drops it on the body, so the next Tab starts again from the top of the document.

  • A Back button that leaves the app

    Swapping views without pushState gives the browser nothing to go back to: Back leaves the app, and people lose their place.

  • Scroll that ignores the history

    A new page that opens halfway down, or a list that forgets where you were when you come back, both disorient. New pages start at the top; Back puts you where you were.

  • Links that only work for plain clicks

    A router that takes over every click breaks Ctrl+click, Cmd+click and middle click. Here only plain clicks are handled; the rest open a new tab as usual.

Notes

Building it

  • On a real site the app's header, nav and page area are the page's own banner, navigation and main landmarks, and the heading is the h1. Here they sit inside this page's main, so the nav is named Demo app and the heading is an h3.
  • Push real paths in your app (/orders) and render the page from the address when it loads, so a reload or a shared link opens the same page. The demo pushes #route-orders instead, so this page keeps its own address; Reset puts it back.
  • Why announce as well as move focus: the heading tells people where they are, and the polite status confirms that a new page has finished loading. That matters for slow pages, whose heading arrives before the content, and for screen readers that say little when focus lands on a heading. Keep it to one short message.
  • Each history entry keeps its own scroll position: saved as you scroll, put back on Back and Forward, while a new page starts at the top. In the demo the scroller is the app's page box; in your app it is the window, so set history.scrollRestoration to manual and save scrollY the same way.
  • The window frame stands in for the browser: its Back and Forward buttons call history.back() and history.forward(), and the option above also changes this tab's real title. Orders fetches its list each time a link opens it, so the loading state shows; Back and Forward reuse the list already there.

Sources: WCAG 2.2 Understanding 2.4.3: Focus Order · WCAG 2.2 Understanding 4.1.3: Status Messages · HTML: the History interface

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction