A11yAdvent Day 7: Page Title in SPA

Single-page applications (SPA for short) have been all the hype for the last decade or so. The idea is that we can avoid reloading the entire page when navigating within a site and instead update only the moving parts (usually the content area). This comes from a great premise: faster interactions, no unnecessary HTTP roundtrips, less used bandwidth.

The thing we usually don’t think about is that many assistive technologies such as screen-readers have been initially authored with the “original web” in mind and rely on page (re)loads to announce the page context, namely the page title (hold by the element).

When building a SPA—no matter the framework—it is important to do some work to announce the title when following router links. Two things need to happen:

  1. The title of the new view/page needs to be announced.
  2. The focus needs to be preserved or moved to a proper place.

A nice solution is to have a visually hidden element at the top of the page which receives the new title when navigating, and move the focus on that element so the content is read. Ideally, the skip link lives right after that node so the flow goes like this:

  1. Press a link in the content area that causes a router change.
  2. The view gets loaded.
  3. The title for that view gets rendered in the invisible node.
  4. The focus gets move to that node so its content is announced.
  5. Tabbing once gets to the skip link, so getting back to the content area is fast and convenient.

Here is how our HTML should look like:

body>
p tabindex="-1" class="sr-only">p>
a href="#main" class="sr-only sr-only--focusable">Skip to contenta>

body>

And our unflavoured JavaScript. Note that this is no specific framework—it’s just a made-up API to illustrate the concept.

const titleHandler = document.querySelector('body > p')

router.on('page:change', ({ title }) => {
// Render the title of the new page in the


titleHandler.innerText = title
// Focus it—note that it *needs* `tabindex="-1"` to be focusable!
titleHandler.focus()
})

You can find a more in-depth tutorial for React with react-router and react-helmet on this blog. The core concept should be the same no matter the framework.

Note that if you have can guarantee there is always a relevant

element (independently of loading states, query errors and such), another possibly simpler solution would be to skip that hidden element altogether, and focus the

element instead (still with tabindex="-1").


This content originally appeared on Hugo “Kitty” Giraudel and was authored by Hugo “Kitty” Giraudel

Single-page applications (SPA for short) have been all the hype for the last decade or so. The idea is that we can avoid reloading the entire page when navigating within a site and instead update only the moving parts (usually the content area). This comes from a great premise: faster interactions, no unnecessary HTTP roundtrips, less used bandwidth.

The thing we usually don’t think about is that many assistive technologies such as screen-readers have been initially authored with the “original web” in mind and rely on page (re)loads to announce the page context, namely the page title (hold by the </code> element).</p> <p>When building a SPA—no matter the framework—it is important to do some work to announce the title when following router links. Two things need to happen:</p> <ol> <li>The title of the new view/page needs to be announced.</li> <li>The focus needs to be preserved or moved to a proper place.</li> </ol> <p>A nice solution is to have a <a href="https://hugogiraudel.com/2020/12/03/a11y-advent-hiding-content">visually hidden</a> element at the top of the page which receives the new title when navigating, and move the focus on that element so the content is read. Ideally, the <a href="https://hugogiraudel.com/2020/12/06/a11y-advent-skip-to-content">skip link</a> lives right after that node so the flow goes like this:</p> <ol> <li>Press a link in the content area that causes a router change.</li> <li>The view gets loaded.</li> <li>The title for that view gets rendered in the invisible node.</li> <li>The focus gets move to that node so its content is announced.</li> <li>Tabbing once gets to the skip link, so getting back to the content area is fast and convenient.</li> </ol> <p>Here is how our HTML should look like:</p> <pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation"><</span>body</span><span class="token punctuation">></span></span><br> <span class="token tag"><span class="token tag"><span class="token punctuation"><</span>p</span> <span class="token attr-name">tabindex</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>-1<span class="token punctuation">"</span></span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>sr-only<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>…<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>p</span><span class="token punctuation">></span></span><br> <span class="token tag"><span class="token tag"><span class="token punctuation"><</span>a</span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>#main<span class="token punctuation">"</span></span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>sr-only sr-only--focusable<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Skip to content<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>a</span><span class="token punctuation">></span></span><br> <span class="token comment"><!-- Rest of the page --></span><br><span class="token tag"><span class="token tag"><span class="token punctuation"></</span>body</span><span class="token punctuation">></span></span></code></pre> <p>And our unflavoured JavaScript. Note that this is no specific framework—it’s just a made-up API to illustrate the concept.</p> <pre class="language-js"><code class="language-js"><span class="token keyword">const</span> titleHandler <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">querySelector</span><span class="token punctuation">(</span><span class="token string">'body > p'</span><span class="token punctuation">)</span><br><br>router<span class="token punctuation">.</span><span class="token function">on</span><span class="token punctuation">(</span><span class="token string">'page:change'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span> title <span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span><br> <span class="token comment">// Render the title of the new page in the <p></span><br> titleHandler<span class="token punctuation">.</span>innerText <span class="token operator">=</span> title<br> <span class="token comment">// Focus it—note that it *needs* `tabindex="-1"` to be focusable!</span><br> titleHandler<span class="token punctuation">.</span><span class="token function">focus</span><span class="token punctuation">(</span><span class="token punctuation">)</span><br><span class="token punctuation">}</span><span class="token punctuation">)</span></code></pre> <p>You can find a more in-depth <a href="https://hugogiraudel.com/2020/01/15/accessible-title-in-a-single-page-react-application/">tutorial for React with <code>react-router</code> and <code>react-helmet</code></a> on this blog. The core concept should be the same no matter the framework.</p> <div class="Info"><p>Note that if you have can guarantee there is <strong>always</strong> a relevant <code><h1></code> element (independently of loading states, query errors and such), another possibly simpler solution would be to skip that hidden element altogether, and focus the <code><h1></code> element instead (still with <code>tabindex="-1"</code>).</p> </div> <p class="syndicated-attribution"><br>This content originally appeared on <a href="https://hugogiraudel.com/2020/12/07/a11y-advent-page-title-in-spa/" target="_blank">Hugo “Kitty” Giraudel</a> and was authored by Hugo “Kitty” Giraudel<br></p> <br> <style> .ap-print-btn-comment { display:none; } .single .ap-print-btn-comment { display:block; } #respond { visibility:hidden; } .entry-content .ap-print-btn { display:none!important; } .entry-content .singleshow .ap-print-btn { display:block!important; } /* #respond { display:none; } */ </style> <script> function showhidecommentbox() { var x = document.getElementById("respond"); if (x.style.visibility === "visible") { x.style.visibility = "hidden"; } else { x.style.visibility = "visible"; } /* if (x.style.display === "block") { x.style.display = "none"; } else { x.style.display = "block"; } */ } </script> <div class="singleshow"> <a href="?printer_app=1" class="ap-print-btn" style=" padding: 15px; margin-top: 10px; width: 140px; text-align: center; border-radius: 3px; font-size: 14px; box-shadow: none !important; background-color: ; color: #ffffff;float:left; margin-right:10px;text-decoration: none;">Print</a> <a onclick="pop()" class="ap-print-btn ap-print-btn-comment" style="cursor:pointer; padding: 15px; margin-top: 10px; width: 140px; text-align: center; border-radius: 3px; font-size: 14px; box-shadow: none !important; background-color: ; color: #ffffff;float:left; margin-right:10px;text-decoration: none;">Share</a> <a onclick="popcomment()" class="ap-print-btn ap-print-btn-comment" style="cursor:pointer; padding: 15px; margin-top: 10px; width: 140px; text-align: center; border-radius: 3px; font-size: 14px; box-shadow: none !important; background-color: ; color: #ffffff;float:left; margin-right:10px;text-decoration: none;">Comment</a> <a onclick="popcite()" class="ap-print-btn ap-print-btn-comment" style="cursor:pointer; padding: 15px; margin-top: 10px; width: 140px; text-align: center; border-radius: 3px; font-size: 14px; box-shadow: none !important; background-color: ; color: #ffffff;float:left; margin-right:10px;text-decoration: none;">Cite</a> <a onclick="popupload()" class="ap-print-btn ap-print-btn-comment" style="cursor:pointer; padding: 15px; margin-top: 10px; width: 140px; text-align: center; border-radius: 3px; font-size: 14px; box-shadow: none !important; background-color: ; color: #ffffff;float:left; margin-right:10px;text-decoration: none;">Upload</a> <a onclick="poptranslate()" class="ap-print-btn ap-print-btn-comment" style="cursor:pointer; padding: 15px; margin-top: 10px; width: 140px; text-align: center; border-radius: 3px; font-size: 14px; box-shadow: none !important; background-color: ; color: #ffffff;float:left; margin-right:10px;text-decoration: none;">Translate</a> <script> function pop() { var popup = document.getElementById('sharepopup'); popup.classList.toggle('show'); } function popcite() { var popup = document.getElementById('citationarea'); popup.classList.toggle('show'); } function popupload() { var popup = document.getElementById('uploadarea'); popup.classList.toggle('show'); } function poptranslate() { var popup = document.getElementById('translatearea'); popup.classList.toggle('show'); } function popcomment() { var popup = document.getElementById('commentarea'); popup.classList.toggle('show'); var x = document.getElementById("respond"); if (x.style.visibility === "visible") { x.style.visibility = "hidden"; } else { x.style.visibility = "visible"; } } </script> <style> .show{ display: grid !important; grid-template-columns: 70px auto; width: 100%; height: 100%; } #respond { margin-left: -40px; } .popup { display: inline-block; } .popup .popuptext { visibility: hidden; background-color: #000000; color: #fff; border-radius: 3px; padding:10px; position:relative; margin:10px 0; } .popuptext { background-color: #000000; color: #fff; border-radius: 3px; padding:10px; position:relative; margin:10px 0; } .popup { width: 100%; } .popup .show { visibility: visible; } #commentarea{ display: none; } #citationarea { display: none; } #commentarea .show{ display: inline !important; visibility: visible !important; background-color: #ffdb14; } #citationarea { display: none; } #citationarea .show { display: inline !important; visibility: visible !important; background-color: #ffdb14; } #uploadarea { display: none; } #uploadarea .show { display: inline !important; visibility: visible !important; background-color: #ffdb14; } #translatearea { display: none; } #translatearea .show { display: inline !important; visibility: visible !important; background-color: #ffdb14; } .sharedashicons { color: white; text-decoration: none; padding: 5px 0; } #translatearea textarea { max-width: 580px; min-width: 100% !important; } </style> <div class="popup"> <span class="popuptext" id="sharepopup"> <div class="sharelogo" id="emailsharelogo"><a href="mailto:info@example.com?&subject=A11yAdvent Day 7: Page Title in SPA&body=https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/"><span class="sharedashicons dashicons dashicons-email"></span></a></div> <div class="sharelogo" id="facebooksharelogo"><a href="https://www.facebook.com/sharer/sharer.php?u=https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/"><span class="sharedashicons dashicons dashicons-facebook"></span></a></div> <div class="sharelogo" id="twittersharelogo"><a href="https://twitter.com/intent/tweet?url=https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/&text=A11yAdvent Day 7: Page Title in SPA"><span class="sharedashicons dashicons dashicons-twitter"></span></a></div> <div class="sharelogo" id="urlsharelogo"><a href="https://twitter.com/intent/tweet?url=https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/&text=A11yAdvent Day 7: Page Title in SPA"><span class="sharedashicons dashicons dashicons-admin-links" onclick="myFunction();"></span></a></div> </span> </div> <div class="commentpopup" style"width: 100%;"> <span class="" id="commentarea"> <img src="https://www.radiofree.org/wp-content/plugins/print-app/icon.jpg" width="100%"> <div class="comments-wrapper"> </div><!-- .comments-wrapper --> </span> </div> <div class="citationpopup" style"width: 100%;"> <span class="popuptext" id="citationarea"> <span class=“citestyle”>APA</span><div class="citationblock" id="content-1">Hugo “Kitty” Giraudel | Sciencx (2023-12-06T04:56:51+00:00) <i> » A11yAdvent Day 7: Page Title in SPA</i>. Retrieved from https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/.</div> <span class=“citestyle”>MLA</span><div class="citationblock" id="content-2">" » A11yAdvent Day 7: Page Title in SPA." Hugo “Kitty” Giraudel | Sciencx - Monday December 7, 2020, https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/</div> <span class=“citestyle”>HARVARD</span><div class="citationblock" id="content-3">Hugo “Kitty” Giraudel | Sciencx Monday December 7, 2020 » A11yAdvent Day 7: Page Title in SPA., viewed 2023-12-06T04:56:51+00:00,<https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/></div> <span class=“citestyle”>VANCOUVER</span><div class="citationblock" id="content-4">Hugo “Kitty” Giraudel | Sciencx - » A11yAdvent Day 7: Page Title in SPA. [Internet]. [Accessed 2023-12-06T04:56:51+00:00]. Available from: https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/</div> <span class=“citestyle”>CHICAGO</span><div class="citationblock" id="content-5">" » A11yAdvent Day 7: Page Title in SPA." Hugo “Kitty” Giraudel | Sciencx - Accessed 2023-12-06T04:56:51+00:00. https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/</div> <span class=“citestyle”>IEEE</span><div class="citationblock" id="content-6">" » A11yAdvent Day 7: Page Title in SPA." Hugo “Kitty” Giraudel | Sciencx [Online]. Available: https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/. [Accessed: 2023-12-06T04:56:51+00:00]</div> <span class=“citestyle”>rf:citation</span><div class="citationblock" id="content-7"> » A11yAdvent Day 7: Page Title in SPA | Hugo “Kitty” Giraudel | Sciencx | https://www.scien.cx/2020/12/07/a11yadvent-day-7-page-title-in-spa/ | 2023-12-06T04:56:51+00:00</div> </span> </div> </span> </div> <div class="citationpopup" style"width: 100%;"> <span class="popuptext" id="uploadarea"> https://github.com/addpipe/simple-recorderjs-demo </span> </div> <div class="translatepopup" style"width: 100%;"> <span class="popuptext" id="translatearea"> <container id="translationcontainer" class="translationcontainer"> <form id="new_post" name="new_post" method="post" enctype="multipart/form-data"> <input type="text" id="title" value="" placeholder="TRANSLATE TITLE: A11yAdvent Day 7: Page Title in SPA" tabindex="1" size="20" name="title" /> <div id="wp-content-wrap" class="wp-core-ui wp-editor-wrap html-active"><link rel='stylesheet' id='editor-buttons-css' href='https://www.scien.cx/wp-includes/css/editor.min.css?ver=6.4.1' type='text/css' media='all' /> <div id="wp-content-editor-container" class="wp-editor-container"><div id="qt_content_toolbar" class="quicktags-toolbar hide-if-no-js"></div><textarea class="wp-editor-area" rows="20" cols="40" name="content" id="content">Use this space to make a translated version of this post.</textarea></div> </div> <input type="text" value="" tabindex="5" size="16" name="post_tags" placeholder="TRANSLATE TAGS: " id="post_tags" /></p> <input type="file" name="post_image" id="post_image" aria-required="true"> <select data-placeholder="Choose a Language..."> <option value="AF">Afrikaans</option> <option value="SQ">Albanian</option> <option value="AR">Arabic</option> <option value="HY">Armenian</option> <option value="EU">Basque</option> <option value="BN">Bengali</option> <option value="BG">Bulgarian</option> <option value="CA">Catalan</option> <option value="KM">Cambodian</option> <option value="ZH">Chinese (Mandarin)</option> <option value="HR">Croatian</option> <option value="CS">Czech</option> <option value="DA">Danish</option> <option value="NL">Dutch</option> <option value="EN">English</option> <option value="ET">Estonian</option> <option value="FJ">Fiji</option> <option value="FI">Finnish</option> <option value="FR">French</option> <option value="KA">Georgian</option> <option value="DE">German</option> <option value="EL">Greek</option> <option value="GU">Gujarati</option> <option value="HE">Hebrew</option> <option value="HI">Hindi</option> <option value="HU">Hungarian</option> <option value="IS">Icelandic</option> <option value="ID">Indonesian</option> <option value="GA">Irish</option> <option value="IT">Italian</option> <option value="JA">Japanese</option> <option value="JW">Javanese</option> <option value="KO">Korean</option> <option value="LA">Latin</option> <option value="LV">Latvian</option> <option value="LT">Lithuanian</option> <option value="MK">Macedonian</option> <option value="MS">Malay</option> <option value="ML">Malayalam</option> <option value="MT">Maltese</option> <option value="MI">Maori</option> <option value="MR">Marathi</option> <option value="MN">Mongolian</option> <option value="NE">Nepali</option> <option value="NO">Norwegian</option> <option value="FA">Persian</option> <option value="PL">Polish</option> <option value="PT">Portuguese</option> <option value="PA">Punjabi</option> <option value="QU">Quechua</option> <option value="RO">Romanian</option> <option value="RU">Russian</option> <option value="SM">Samoan</option> <option value="SR">Serbian</option> <option value="SK">Slovak</option> <option value="SL">Slovenian</option> <option value="ES">Spanish</option> <option value="SW">Swahili</option> <option value="SV">Swedish </option> <option value="TA">Tamil</option> <option value="TT">Tatar</option> <option value="TE">Telugu</option> <option value="TH">Thai</option> <option value="BO">Tibetan</option> <option value="TO">Tonga</option> <option value="TR">Turkish</option> <option value="UK">Ukrainian</option> <option value="UR">Urdu</option> <option value="UZ">Uzbek</option> <option value="VI">Vietnamese</option> <option value="CY">Welsh</option> <option value="XH">Xhosa</option> </select> <input type="text" id="lname" name="lname" placeholder="Author"><br><br> <p><input type="submit" value="Publish" tabindex="6" id="submit" name="submit" /></p> </form> </container> </span> </div> </div><!-- .entry-content --> <nav class="pagination-single border-color-border"> <a class="previous-post" href="https://www.scien.cx/2020/12/06/static-web-apps-on-devops-labs/"> <span class="arrow">←</span> <span class="title"><span class="title-inner">Static Web Apps on DevOps Labs</span></span> </a> <a class="next-post" href="https://www.scien.cx/2020/12/07/live-first-time-casual-hindi-talks/"> <span class="arrow">→</span> <span class="title"><span class="title-inner">Live First Time : Casual Hindi Talks</span></span> </a> </nav><!-- .single-pagination --> </div><!-- .post-inner --> </article><!-- .post --> <div class="related-posts section-inner"> <h2 class="related-posts-title heading-size-3">Related Posts</h2> <div class="posts"> <div class="posts-grid related-posts-grid grid mcols-1 tcols-2 tlcols-3 dcols-4"> <div class="grid-item"> <article class="preview preview-post post-581906 post type-post status-publish format-standard hentry category-api category-documentation category-productivity category-webdev" id="post-581906">