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 Orders · Panna Books Settings · Panna Books
Panna Books
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.
| Key | What it does |
|---|---|
| Tab | Moves through the window's Back and Forward buttons, the app's links, then the open page; the pages that are not shown are skipped. |
| Enter | On 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 Right | The 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 Space | On 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. |
| CtrlEnter | On 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 Down | With 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.
| When | Expected announcement |
|---|---|
| Tab reaches the app's links | Demo app, navigation, list, 3 items. Home, link, current page |
| A link opens Settings | Settings, heading level 3. Then, politely: Settings page loaded |
| A link opens Orders, whose list takes a moment | Orders, heading level 3. When the list has arrived: Orders page loaded |
| Back returns to Home | Home, heading level 3. Then: Home page loaded |
| Focus reaches the window's Back button | Browser, 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.
<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>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Route change. Uses the --ap-* design tokens and the shared primitives. */
.ap-route {
width: min(100%, 640px);
margin-inline: auto;
color: var(--ap-text);
}
.ap-route [hidden] {
display: none !important;
}
/* The browser window: a tab with the document title, Back and Forward, and the address. */
.ap-route__window {
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-md);
overflow: hidden;
container-type: inline-size;
}
.ap-route__chrome {
padding: 10px 12px 0;
background: var(--ap-surface-2);
}
.ap-route__tab {
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 100%;
margin: 0;
padding: 8px 16px 9px 12px;
border: 1px solid var(--ap-border);
border-bottom: 0;
border-radius: var(--ap-radius-sm) var(--ap-radius-sm) 0 0;
background: var(--ap-surface);
color: var(--ap-text-2);
font-size: .8125rem;
font-weight: 500;
line-height: 1.35;
white-space: nowrap;
}
.ap-route__tab [data-ap-route-title] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.ap-route__favicon {
flex: none;
width: 14px;
height: 14px;
border-radius: 4px;
background: var(--ap-accent);
}
.ap-route.is-loading .ap-route__favicon {
border: 2px solid var(--ap-surface-3);
border-top-color: var(--ap-accent);
border-radius: 50%;
background: transparent;
animation: ap-route-spin 800ms linear infinite;
}
.ap-route__toolbar {
display: flex;
align-items: center;
gap: 2px;
margin: 0 -12px;
padding: 6px 10px;
border-top: 1px solid var(--ap-border);
border-bottom: 1px solid var(--ap-border);
background: var(--ap-surface);
}
.ap-route__hist {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: var(--ap-radius-full);
background: transparent;
color: var(--ap-text-2);
cursor: pointer;
}
.ap-route__hist:hover:not(:disabled) {
background: var(--ap-surface-2);
color: var(--ap-text);
}
.ap-route__hist:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
}
.ap-route__hist:disabled {
opacity: .4;
cursor: default;
}
.ap-route__address {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
min-height: 34px;
margin: 0 0 0 6px;
padding: 0 14px;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-2);
color: var(--ap-text-2);
font-size: .8125rem;
white-space: nowrap;
}
.ap-route__address [data-ap-route-url] {
overflow: hidden;
text-overflow: ellipsis;
}
.ap-route__lock {
flex: none;
width: 14px;
height: 14px;
fill: none;
stroke: var(--ap-text-3);
stroke-width: 2;
stroke-linecap: round;
}
.ap-route__icon {
flex: none;
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The app: its header with the navigation, then the page box. */
.ap-route__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 16px;
padding: 12px 16px 12px 20px;
border-bottom: 1px solid var(--ap-border);
}
.ap-route__brand {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: -.01em;
}
.ap-route__logo {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: var(--ap-radius-sm);
background: var(--ap-accent);
color: var(--ap-on-accent);
}
.ap-route__nav ul {
display: flex;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.ap-route__link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 38px;
padding: 0 14px 0 12px;
border-radius: var(--ap-radius-full);
color: var(--ap-text-2);
font-size: .9375rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: background-color var(--ap-duration) var(--ap-ease), color var(--ap-duration) var(--ap-ease);
}
.ap-route__link .ap-route__icon {
width: 17px;
height: 17px;
}
.ap-route__link:hover {
background: var(--ap-surface-2);
color: var(--ap-text);
}
.ap-route__link[aria-current="page"] {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
font-weight: 650;
box-shadow: inset 0 0 0 1px var(--ap-accent);
}
.ap-route__link:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-route__viewport {
position: relative;
}
/* A thin bar while a page is loading. Decoration: the page itself says so in words. */
.ap-route__progress {
position: absolute;
inset: 0 0 auto;
z-index: 2;
height: 3px;
overflow: hidden;
opacity: 0;
transition: opacity var(--ap-duration) var(--ap-ease);
}
.ap-route__progress::before {
content: "";
position: absolute;
inset: 0;
width: 40%;
border-radius: var(--ap-radius-full);
background: var(--ap-accent);
animation: ap-route-progress 1100ms var(--ap-ease) infinite;
}
.ap-route.is-loading .ap-route__progress {
opacity: 1;
}
/* The page box: the app's own scroller, standing in for the window. */
.ap-route__page {
height: 340px;
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-top: 64px;
scrollbar-width: thin;
}
/* Nothing to scroll through until the content has arrived. */
.ap-route.is-loading .ap-route__page {
overflow-y: hidden;
}
.ap-route__view {
padding: 0 20px 22px;
}
.ap-route__view:not([hidden]) {
animation: ap-route-in 200ms var(--ap-ease);
}
/* The heading sticks, so a focused heading stays in view at any scroll position. */
.ap-route__top {
position: sticky;
top: 0;
z-index: 1;
margin: 0 -20px;
padding: 18px 20px 8px;
background: var(--ap-surface);
}
/* A hairline under the heading once the page scrolls beneath it, where supported. */
@supports (animation-timeline: scroll()) {
.ap-route__top {
animation: ap-route-shade linear both;
animation-timeline: scroll();
animation-range: 0 24px;
}
}
.ap-route__h {
width: fit-content;
margin: 0;
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -.015em;
line-height: 1.25;
}
.ap-route__h:focus {
outline: none;
}
.ap-route__h:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 3px;
border-radius: 4px;
}
.ap-route__lead {
max-width: 54ch;
margin: 0 0 18px;
color: var(--ap-text-2);
line-height: 1.55;
}
/* Home */
.ap-route__cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.ap-route__card {
display: grid;
align-content: start;
justify-items: start;
gap: 6px;
padding: 16px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
}
.ap-route__card p {
margin: 0 0 4px;
color: var(--ap-text);
font-weight: 550;
line-height: 1.45;
}
.ap-route__badge {
display: grid;
place-items: center;
width: 34px;
height: 34px;
margin-bottom: 4px;
border-radius: var(--ap-radius-full);
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
}
.ap-route__card-name {
margin: 0;
color: var(--ap-text-3);
font-size: .75rem;
font-weight: 650;
letter-spacing: .06em;
text-transform: uppercase;
}
.ap-route__more {
display: inline-flex;
align-items: center;
min-height: 32px;
margin-top: auto;
color: var(--ap-accent-text);
font-weight: 600;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: .2em;
}
.ap-route__more:hover {
text-decoration-thickness: 2px;
}
.ap-route__more:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
border-radius: 2px;
}
/* Orders, with its loading state */
.ap-route__loading {
display: grid;
gap: 12px;
}
.ap-route__wait {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
color: var(--ap-text-2);
font-weight: 600;
}
.ap-route__spinner {
flex: none;
width: 18px;
height: 18px;
border: 2px solid var(--ap-surface-3);
border-top-color: var(--ap-accent);
border-radius: 50%;
animation: ap-route-spin 800ms linear infinite;
}
.ap-route__skel {
display: grid;
gap: 8px;
}
.ap-route__skel span {
height: 62px;
border-radius: var(--ap-radius);
background: linear-gradient(90deg, var(--ap-surface-2) 30%, var(--ap-surface-3) 50%, var(--ap-surface-2) 70%) 0 0 / 300% 100%;
animation: ap-route-shimmer 1400ms linear infinite;
}
.ap-route__orders {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.ap-route__order {
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
}
.ap-route__order[open] {
border-color: var(--ap-border-strong);
}
.ap-route__order summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
min-height: 62px;
padding: 10px 12px 10px 16px;
border-radius: calc(var(--ap-radius) - 1px);
list-style: none;
cursor: pointer;
}
.ap-route__order summary::-webkit-details-marker {
display: none;
}
.ap-route__order summary:hover {
background: var(--ap-surface-2);
}
.ap-route__order summary:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-route__order-main {
display: grid;
gap: 2px;
min-width: 0;
}
.ap-route__order-id {
font-weight: 650;
}
.ap-route__order-meta {
color: var(--ap-text-3);
font-size: .875rem;
}
.ap-route__chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px 3px 8px;
border-radius: var(--ap-radius-full);
font-size: .75rem;
font-weight: 650;
white-space: nowrap;
}
.ap-route__chip-icon {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-route__chip--way {
background: var(--ap-info-soft);
color: var(--ap-info);
}
.ap-route__chip--done {
background: var(--ap-success-soft);
color: var(--ap-success);
}
.ap-route__chip--back {
background: var(--ap-surface-2);
color: var(--ap-text-2);
}
.ap-route__chevron {
width: 18px;
height: 18px;
fill: none;
stroke: var(--ap-text-3);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--ap-duration) var(--ap-ease);
}
.ap-route__order[open] .ap-route__chevron {
transform: rotate(180deg);
}
.ap-route__items {
display: grid;
gap: 6px;
margin: 0;
padding: 10px 16px 14px;
border-top: 1px solid var(--ap-border);
list-style: none;
}
.ap-route__items li {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 2px 12px;
}
.ap-route__format {
color: var(--ap-text-3);
font-size: .875rem;
}
/* Settings */
.ap-route__group {
min-width: 0;
margin: 0 0 18px;
padding: 0;
border: 0;
}
.ap-route__legend {
margin: 0 0 6px;
padding: 0;
color: var(--ap-text-3);
font-size: .75rem;
font-weight: 650;
letter-spacing: .06em;
text-transform: uppercase;
}
.ap-route__setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 52px;
border-bottom: 1px solid var(--ap-border);
cursor: pointer;
}
.ap-route__switch {
position: relative;
flex: none;
width: 42px;
height: 24px;
margin: 0;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-full);
background: var(--ap-surface-3);
appearance: none;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease);
}
.ap-route__switch::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--ap-surface);
box-shadow: var(--ap-shadow-sm);
transition: transform var(--ap-duration) var(--ap-ease);
}
.ap-route__switch:checked {
border-color: var(--ap-accent);
background: var(--ap-accent);
}
.ap-route__switch:checked::before {
transform: translateX(18px);
}
.ap-route__switch:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-route__person {
margin: 10px 0 2px;
font-weight: 650;
}
.ap-route__address-text {
margin: 0;
color: var(--ap-text-2);
line-height: 1.55;
}
.ap-route__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@container (max-width: 480px) {
.ap-route__bar { padding: 12px 12px 10px; }
.ap-route__brand { width: 100%; padding-left: 4px; }
.ap-route__nav { width: 100%; }
.ap-route__nav li { flex: 1; }
.ap-route__link { width: 100%; padding: 0 10px; }
.ap-route__view { padding: 0 16px 18px; }
.ap-route__top { margin: 0 -16px; padding: 16px 16px 8px; }
.ap-route__cards { grid-template-columns: minmax(0, 1fr); }
.ap-route__order summary { grid-template-columns: minmax(0, 1fr) auto; padding-left: 14px; }
.ap-route__order summary .ap-route__chip { grid-row: 2; grid-column: 1; justify-self: start; }
.ap-route__chevron { grid-row: 1 / 3; grid-column: 2; }
}
@keyframes ap-route-in {
from { opacity: 0; transform: translateY(4px); }
}
@keyframes ap-route-shade {
from { box-shadow: 0 1px 0 transparent; }
to { box-shadow: 0 1px 0 var(--ap-border); }
}
@keyframes ap-route-spin {
to { transform: rotate(360deg); }
}
@keyframes ap-route-progress {
from { transform: translateX(-100%); }
to { transform: translateX(250%); }
}
@keyframes ap-route-shimmer {
from { background-position: 100% 0; }
to { background-position: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.ap-route__view:not([hidden]),
.ap-route__skel span { animation: none; }
/* Loading still shows, standing still. */
.ap-route.is-loading .ap-route__favicon,
.ap-route__spinner { animation: none; }
.ap-route__progress::before { animation: none; width: 60%; }
.ap-route__link,
.ap-route__chevron,
.ap-route__switch,
.ap-route__switch::before,
.ap-route__progress { transition: none; }
}
/**
* Route change: navigation in a single-page app that does what a page load
* does. Each change of page sets the document title and the current link,
* moves focus to the new page's heading, announces the page politely once
* its content is there, and keeps a scroll position for every history entry,
* so Back and Forward return you to where you were.
*
* Markup: [data-ap-route-change] holding links a[href^="#route-"] (in a nav
* and inside the pages), one [data-ap-route-view="<route>"] per page with an
* h3[tabindex="-1"], title spans [data-ap-route-title="<route>"], the
* scrolling page box [data-ap-route-page], the address [data-ap-route-url],
* Back and Forward buttons [data-ap-route-back] [data-ap-route-forward], and
* an empty role=status paragraph [data-ap-route-live]. A page that loads its
* data has a loading block [data-ap-route-loading] and the content it waits
* for [data-ap-route-list]. Add data-set-title to the root to set
* document.title as well.
*
* The demo pushes #route-<name> so the page it sits on keeps its own
* address; in an app, push real paths (/orders) and read them on load.
*/
const KEY = "apRoute"; // our mark in history.state
const PREFIX = "#route-";
const SLOW = new Set(["orders"]); // pages that fetch their data when a link opens them
const LOAD_MS = 1200;
const LOADED = {
home: "Home page loaded",
orders: "Orders page loaded",
settings: "Settings page loaded",
};
const ADDRESS = {
home: "pannabooks.in",
orders: "pannabooks.in/orders",
settings: "pannabooks.in/settings",
};
// Every running copy, so one that was taken out of the page (re-rendered,
// or replaced) can give the address and title back before a new one starts.
const running = new Map();
export function init(root) {
for (const [old, stop] of running) if (!old.isConnected) stop();
const views = new Map([...root.querySelectorAll("[data-ap-route-view]")].map((v) => [v.dataset.apRouteView, v]));
const page = root.querySelector("[data-ap-route-page]");
const live = root.querySelector("[data-ap-route-live]");
const back = root.querySelector("[data-ap-route-back]");
const forward = root.querySelector("[data-ap-route-forward]");
const address = root.querySelector("[data-ap-route-url]");
const navLinks = [...root.querySelectorAll("nav a[href^='#route-']")];
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
const events = new AbortController();
const startTitle = document.title;
const ready = new Set(views.keys()); // pages whose content is on screen
const scrolls = []; // scroll position of each history entry, by index
let current = "home";
let index = 0; // this entry's place in the app's history
let last = 0; // the furthest entry Forward can reach
let base = null; // the address and state the page had before the demo changed them
let titleChanged = false;
let loadTimer = 0;
let sayTimer = 0;
const routeOf = (hash) => {
const name = hash && hash.startsWith(PREFIX) ? hash.slice(PREFIX.length) : "";
return views.has(name) ? name : null;
};
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const ours = (state) => (isObject(state) && isObject(state[KEY]) ? state[KEY] : null);
const hrefFor = (route) => {
const url = new URL(location.href);
url.hash = PREFIX.slice(1) + route;
return url.href;
};
/* The page's own address and state, kept once, so cleanup can put them back. */
function remember() {
if (base) return;
const url = new URL(location.href);
if (routeOf(url.hash)) url.hash = "";
let state = history.state;
if (ours(state)) {
state = { ...state };
delete state[KEY];
if (!Object.keys(state).length) state = null;
}
base = { url: url.href, state, scroll: ours(history.state) ? "auto" : history.scrollRestoration };
}
function write(i, url, push) {
const state = { [KEY]: { index: i } };
if (push) history.pushState(state, "", url);
else history.replaceState({ ...(isObject(history.state) ? history.state : {}), ...state }, "", url);
// The app restores its own scroll position; the browser must not also move the page.
history.scrollRestoration = "manual";
}
/* A link in the app: a new history entry, or a reload of the same page. */
function go(route) {
remember();
if (route === current) {
write(index, hrefFor(route), false); // the same page again: reload it, no new entry
} else {
// The entry being left gets the app's address for what it shows, so Back finds it.
if (routeOf(location.hash) !== current || !ours(history.state)) write(index, hrefFor(current), false);
index += 1;
write(index, hrefFor(route), true);
}
last = index;
scrolls.length = index; // entries a push cuts off are gone
show(route, "link");
}
/* Back, Forward, or an address typed by hand. */
function onHistory() {
const route = routeOf(location.hash);
if (!route) return; // not one of ours, such as a link to a heading on the host page
const mark = ours(history.state);
if (!mark) {
remember();
index += 1;
write(index, location.href, false);
last = index;
scrolls.length = index;
show(route, "link");
return;
}
if (route === current) {
// Already showing it (the hashchange that follows a popstate, for one).
index = mark.index;
last = Math.max(last, index);
setButtons();
return;
}
index = mark.index;
last = Math.max(last, index);
show(route, "history");
}
function setButtons() {
back.disabled = index <= 0;
forward.disabled = index >= last;
}
/* Show a page: views, current link, title, address, scroll, focus, announcement. */
function show(route, how) {
stopLoading();
clearTimeout(sayTimer); // a page left quickly is not announced
current = route;
for (const [name, view] of views) view.hidden = name !== route;
for (const link of navLinks) {
if (routeOf(link.getAttribute("href")) === route) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
}
let title = "";
for (const span of root.querySelectorAll("[data-ap-route-title]")) {
span.hidden = span.dataset.apRouteTitle !== route;
if (!span.hidden) title = span.textContent.trim();
}
if (root.hasAttribute("data-set-title")) {
document.title = title;
titleChanged = true;
}
address.textContent = ADDRESS[route];
// A new page starts at the top; Back and Forward go back to where it was.
const top = how === "history" ? scrolls[index] || 0 : 0;
const load = (how === "link" && SLOW.has(route)) || !ready.has(route);
if (load) startLoading(route, top);
page.scrollTop = top;
if (how !== "init") {
const heading = views.get(route).querySelector("[tabindex='-1']");
// Focus first: a Back button that is about to be switched off may hold it.
heading.focus({ preventScroll: true });
reveal(heading);
if (!load) say(LOADED[route]);
}
setButtons();
}
/* Bring the heading on screen only when focus moved somewhere out of view. */
function reveal(el) {
const box = el.getBoundingClientRect();
if (box.top >= 0 && box.bottom <= innerHeight) return;
window.scrollBy({ top: box.top - innerHeight / 3, behavior: reduce.matches ? "auto" : "smooth" });
}
/* Cleared, then filled after focus has moved, so it queues behind the heading. */
function say(text) {
clearTimeout(sayTimer);
live.textContent = "";
sayTimer = setTimeout(() => { live.textContent = text; }, 150);
}
/* The page's data. A timer stands in for fetch(): the list arrives after LOAD_MS. */
function startLoading(route, top) {
const view = views.get(route);
const loading = view.querySelector("[data-ap-route-loading]");
const list = view.querySelector("[data-ap-route-list]");
if (!loading || !list) return;
ready.delete(route);
loading.hidden = false;
list.hidden = true;
root.classList.add("is-loading");
loadTimer = setTimeout(() => {
loadTimer = 0;
root.classList.remove("is-loading");
loading.hidden = true;
list.hidden = false;
ready.add(route);
page.scrollTop = top;
say(LOADED[route]);
}, LOAD_MS);
}
function stopLoading() {
// Leaving a page mid-load: drop the request; the next visit loads again.
if (!loadTimer) return;
clearTimeout(loadTimer);
loadTimer = 0;
root.classList.remove("is-loading");
}
function onClick(event) {
if (event.target.closest("[data-ap-route-back]")) {
history.back();
return;
}
if (event.target.closest("[data-ap-route-forward]")) {
history.forward();
return;
}
const link = event.target.closest("a[href]");
if (!link || !root.contains(link)) return;
const route = routeOf(link.getAttribute("href"));
// New tabs and windows (Ctrl, Cmd, Shift, middle click) belong to the browser.
if (!route || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
go(route);
}
function onScroll() {
scrolls[index] = page.scrollTop;
}
// On load, the address decides the page, as in an app: a reload, or a link
// opened in a new tab, shows the page it names.
const start = routeOf(location.hash);
if (start) {
remember();
const mark = ours(history.state);
index = last = mark ? mark.index : 0;
if (!mark) write(index, location.href, false);
}
show(start || "home", "init");
root.addEventListener("click", onClick, { signal: events.signal });
page.addEventListener("scroll", onScroll, { passive: true, signal: events.signal });
window.addEventListener("popstate", onHistory, { signal: events.signal });
window.addEventListener("hashchange", onHistory, { signal: events.signal });
function stop() {
if (events.signal.aborted) return; // already stopped
running.delete(root);
events.abort();
clearTimeout(loadTimer);
clearTimeout(sayTimer);
if (base) {
history.replaceState(base.state, "", base.url);
history.scrollRestoration = base.scroll;
}
if (titleChanged) document.title = startTitle;
}
running.set(root, stop);
return stop;
}
for (const root of document.querySelectorAll("[data-ap-route-change]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
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