Pagefold

A11y

Announce the new page to screen readers, move focus to the content and respect reduced motion.

Import it from pagefold/a11y (about 1.9 KB minified and compressed, shared code included).

import PagefoldA11y from 'pagefold/a11y'

new Pagefold({ plugins: [new PagefoldA11y()] })

What it does

  • Announces the page: a visually hidden live region (role="status", aria-live="assertive", or polite with the politeness option) is filled with your message after each visit.
  • Moves focus to the top of the new page (the body, or the contentSelector you choose), so keyboard and screen reader users start at the top of the new page and not on a link that no longer exists. Focus moves even when the link that was clicked has it.
  • Respects reduced motion. If the user asked for less motion, visits are not animated: Pagefold fires animation:skip and swaps the content straight away.
  • Focuses anchors. A link to #section on the same page moves focus to that section.

Where focus goes

After the content is replaced, Pagefold asks this for the focused element:

  1. Is focus in a text field, a select or a contenteditable element?
    Yes → stays
  2. Is it inside something marked data-pagefold-persist?
    Yes → stays
  3. Focus moves to the body, or to contentSelector.

Yes: Focus is left where it is. People should not be interrupted while typing.

When it leaves focus alone

If focus is in a text field, a select, a contenteditable element or inside something marked data-pagefold-persist, it stays there. People should not be interrupted while typing.

The temporary tabindex="-1" it adds to the content is removed when focus leaves.

What is announced

The plugin looks for the name of the page in this order and says the first it finds:

  1. The aria-label of the heading: <h1 aria-label="Homepage">Project title</h1> is read as “Homepage”. It is the easiest way to say something different from the heading.
  2. The text of the heading.
  3. The document title.
  4. If there is none, the address, with the url text.

headingSelector can be a list. The default looks inside main first, so a logo in an h1 outside it does not win.

Several languages

Key the texts by the language of the page. It must match the lang of the html element exactly, and * is the fallback:

new PagefoldA11y({
  announcements: {
    'en-US': { visit: 'Navigated to: {title}', url: 'New page at {url}' },
    'de-DE': { visit: 'Navigiert zu: {title}', url: 'Neue Seite unter {url}' },
    '*': { visit: '{title}', url: '{url}' },
  },
})

Pagefold does not change the lang for you. The Head plugin does it, or you can in content:replace.

Changing it for one visit

The plugin gives every visit an a11y object. Use it to change what is announced or where focus goes:

// The text is only known once the new page is there, so change it just before it is said
pagefold.hooks.before('content:announce', (visit) => {
  visit.a11y.announce = 'Results updated'   // or false to say nothing
})

pagefold.hooks.on('visit:start', (visit) => {
  // Focus <main>, and wait until the visit is over instead of doing it right after the swap
  visit.a11y.focus = { selector: 'main', wait: true }
  // visit.a11y.focus = false    turns focus handling off for this visit
})

The hooks content:announce and content:focus fire when the announcement is made and when focus moves. A fragment rule can name the element to focus with focus: '#detail', or focus: false.

Announcing something yourself

When the page changes without a visit, such as a filter on a list, say so:

pagefold.announce?.('Filtered by cats')

Tip

Use a heading in each page’s content. The announcement reads the first match of headingSelector, so a clear h1 makes the message useful.

Common problems

  • The announcement is empty or says the wrong thing. The title comes from the first match of headingSelector. Make sure each page has one h1.
  • An outline appears around the content after a visit. Focus moves there so keyboard users start at the top. The content is not interactive, so you can hide the ring: #pagefold:focus { outline: none }.
  • Animations do not play for some visitors. They asked their system for reduced motion, and the plugin respects it.

Options

Option Type Default What it does
announce boolean true Tell screen readers about the new page.
announcements { visit, url } | Record<lang, { visit, url }> { visit: 'Navigated to: {title}', url: 'New page at {url}' } What is said. visit is used when the page has a title, url when it has none. Key it by language for several.
headingSelector string | string[] ['main h1', 'h1'] Where to look for the heading, in order. The document title is the fallback.
autofocus boolean false Focus the element with an autofocus attribute in the new content.
focus boolean true Move focus to the new content after a visit.
contentSelector string 'body' The element that receives focus. Use 'main' or a container selector to focus the new content instead.
politeness 'assertive' | 'polite' 'assertive' How urgently screen readers read the announcement: assertive interrupts, polite waits.
respectReducedMotion boolean true Skip animations when the user prefers reduced motion.
inertLeaving boolean true Make the content that is leaving inert while it animates out, so it cannot be clicked or focused. It is given back if the visit is aborted. Elements the page made inert itself are left alone.