Shipping a theme
Accessibility in Block Themes Is Structural
A block theme gets skip links, landmarks and heading order from template markup a user can rearrange, so accessibility becomes structural rather than cosmetic.
Open a block theme a reviewer rejected for accessibility and you will usually find the problem nowhere near the CSS. The skip link points at nothing, two headings both claim to be the page title, a navigation landmark has no name, or an accordion announces itself as a button with no state. None of those are visual. All of them come from markup the template decides and that a site owner can change in the Site Editor after you ship.
That is the shift this lesson is about. In a classic theme the accessibility tree was mostly a function of the PHP you wrote, and you could reason about it by reading your own files. In a block theme it is a function of the block structure, which is data, which the user owns. Your job is to make the default structure correct and to know which parts of it break loudly and which break silently.
The skip link needs a main element and nothing else
Until recently the skip link was assembled in the browser by a small script that looked
for main. In WordPress 7.0 it is inserted on the server, which makes the
dependency easier to see and harder to miss. Both hooks still have to be present for it to
run at all:
// The template HTML is only decorated if these two actions are attached.
if (
! has_action( 'wp_footer', 'the_block_template_skip_link' ) ||
! has_action( 'wp_enqueue_scripts', 'wp_enqueue_block_template_skip_link' )
) {
return $template_html;
}
return _block_template_add_skip_link( $template_html );
The function then walks the finished template with the HTML API, bookmarks the first
div.wp-site-blocks, looks for the first main element after it, and
writes the link immediately before the wrapper. If the main already has an
id, that id is the target; otherwise it adds
id="wp--skip-link--target". My test theme produced this from a template whose only
landmark was a group with "tagName":"main":
<a class="skip-link screen-reader-text" id="wp-skip-link" href="#wp--skip-link--target">Skip to content</a>
<div class="wp-site-blocks">
<header class="wp-block-template-part">...</header>
<main id="wp--skip-link--target" class="wp-block-group is-layout-constrained ...">
Now change one attribute in the template file, or let a site owner change the group's tag
in the editor, and the result is not a worse skip link. There is no skip link. I rendered the
same template with "tagName":"div" and the entire a.skip-link element
was absent from the output, because the function returns the untouched template as soon as
next_tag( 'MAIN' ) finds nothing.
The skip link is a property of the markup, not of the theme
Nothing in the theme files records that a skip link exists, so nothing fails at build time when it disappears. The styles for the link are still enqueued, which is why the bug looks like a missing stylesheet. The theme review guidelines state the requirement plainly: the link must be the first focusable element, visible on focus, and it must move focus to the main content area.
A template part adds a landmark, not a div
Template parts are inserted as their own element, and the element name comes from the
part. Ask for {"slug":"header","tagName":"header"} and you get a
header wrapper around whatever the part contains. My header part contains a
group whose tag name is also header, so the rendered page has two nested
header landmarks:
<header class="wp-block-template-part">
<header class="wp-block-group is-layout-constrained">
...
</header>
</header>
Nesting a banner inside a banner is legal HTML and assistive technology copes with it, but it explains a class of review comment. Two landmarks with the same role and no accessible name are announced as two unnamed banners, and a screen reader user navigating by landmark cannot tell which is which. The same applies to a template with two navigation blocks: identical roles, indistinguishable unless one has a name.
The practical rule is that every landmark you generate more than once needs a name, and
every landmark you generate once needs to be the right element. A group of links is not a
nav until you say so, and a column of footer text is not a footer
until the tag name says it is.
Heading order is a decision the user can undo
WordPress does not renumber headings for you. It renders the level each block is configured with, so the document outline is the sum of individual block settings. I built the default shape by leaving the site title block alone and adding a post title block, which is what a pattern assembled from the inserter does:
h1 M6 Lab
h1 A post with a quote
Two top-level headings is the most common structural finding in a block theme, and it comes from a defensible decision. Putting the site title in the header is normal, and giving the post title a level 1 is normal, and the two together are wrong. Either the site title becomes a paragraph or a lower level on inner templates, or the document has two titles.
The reason this is not a one-line fix is that the Site Editor lets a site owner edit
every template, including yours. A query loop renders each post title at the level set on the
block, so an archive whose post templates use h1 produces one h1 per
result. Locking the template is a different product. What you can do is ship a default outline
that survives the obvious edits: one h1 per view, the query title block for
archive headings, and levels chosen so that inserting a section heading between two existing
headings still lands in order.
Navigation labels come from the block, not from the theme
The navigation block has an ariaLabel attribute and its render callback
emits it on the nav element. If the attribute is empty the render callback
falls back to the title of the navigation post, and if there is no navigation post either,
the nav element is rendered with no accessible name at all. I confirmed both
cases by editing the part:
<!-- ariaLabel set to "Primary" -->
<nav class="is-responsive wp-block-navigation ..." aria-label="Primary">
<!-- ariaLabel absent -->
<nav class="is-responsive wp-block-navigation ...">
Two more names are in play that have nothing to do with that attribute. The responsive
overlay is a dialog whose label comes from the block's Interactivity context, defaulting to
the translated string Menu, and the open button gets
aria-label="Open menu". Since a block theme often has a header navigation and a
footer navigation, set a distinct ariaLabel on each navigation block in the parts
you ship.
Contrast is enforced in the editor, not in the palette
The editor ships a contrast checker that runs while someone is styling a block. In 7.0.7 it tests the text colour against the background, with a link colour when one is set, using WCAG level AA and choosing the large-text threshold on a font size of 24 pixels or more. It reports a warning notice and does not block saving.
It is worth being precise about what this is not. The theme.json schema for WordPress 7.0
does not define a contrast key on a palette entry: items allow
name, slug and color, with
"additionalProperties": false. I checked the resolved data after adding a
contrast value to two entries, and WordPress kept the extra key in the theme's raw
data while no core code reads it. The only real control is which colours you put in the
palette. From the palette in this course's running example, the accent green is fine and the
default core palette is not:
| Pair | Ratio | AA body text |
|---|---|---|
#1f6f5c on #ffffff | 6.02:1 | Pass |
#14161a on #ffffff | 18.11:1 | Pass |
#abb8c3 on #ffffff | 2.02:1 | Fail |
#7bdcb5 on #ffffff | 1.65:1 | Fail |
#ffffff on #fcb900 | 1.74:1 | Fail |
Those three failures are entries in the default palette WordPress supplies. A user who picks "Luminous vivid amber" for a button background and leaves the text white gets a one-point-seven ratio and a warning they can dismiss. A theme that ships a small palette whose every pair is usable removes that choice, which is a design decision made for accessibility reasons.
Core blocks carry their own semantics, and drop them without scripting
The interesting block behaviour in WordPress 7.0 is not in the CSS. The accordion family of blocks renders state on the server, and its keyboard support lives in an Interactivity API module. Here is what the server produced for a collapsed item:
<h3 class="wp-block-accordion-heading"><button aria-expanded="false" aria-controls="accordion-item-2-panel"
data-wp-bind--aria-expanded="state.isOpen" data-wp-on--click="actions.toggle"
data-wp-on--keydown="actions.handleKeyDown" id="accordion-item-2"
class="wp-block-accordion-heading__toggle">...</button></h3>
<div inert aria-labelledby="accordion-item-2" data-wp-bind--inert="!state.isOpen"
id="accordion-item-2-panel" class="wp-block-accordion-panel">...</div>
That markup is a usable disclosure even before the module loads: a real button with
aria-expanded, a panel labelled by its button, and inert on the
collapsed panel so it is not reachable while closed. The module then takes over
aria-expanded and adds arrow, Home and End keys that move focus between toggles.
The details block goes further and uses the native details/summary
pair, which needs no scripting at all.
So the accessibility trap is rarely in a core block. It is in what a theme does around
one. A theme that reimplements a toggle as a div with a click handler has lost
the state, the focusability and the keyboard behaviour at once, and a theme that sets
outline: none on :focus has removed the only thing a keyboard user
can see. A slider block from a plugin is a different problem: it is markup you did not write,
and the only reliable answer is to operate it with the keyboard and watch whether the slide
change is announced. If the block renders visual panels with no focusable control, no
stylesheet fixes it.
Automated checkers pass themes people cannot use
A checker can tell you that an image has no alt attribute or that a contrast
ratio is below a threshold. It cannot tell you that focus moves into the mobile navigation
overlay and never comes back out, that tabbing through a header takes nineteen presses, or
that a filter button replaces the post list without announcing anything. Those are the
failures that make a theme unusable, and every one of them passes an automated audit.
Two tests catch most of them, and neither needs a tool. The keyboard test: put the mouse away, press Tab from the address bar, and go through the page looking for the skip link first, a visible focus indicator on every stop, an order that matches the visual order, and the ability to open and close every interactive thing and get out again. The screen reader test: navigate by landmarks and headings. If the landmark list shows two unnamed banners and the heading list shows two level ones, the structure is wrong regardless of how the page looks.
Take a block theme you have built and audit one single post by reading the rendered
HTML, not the templates. Use curl against your local site and answer four
questions with the actual output pasted next to them:
Which headings are present, in order, and how many are level one? Which landmarks
exist, and how many share a role with no accessible name? What is the
href of the skip link, and does that target exist in the same document?
Does every nav element have an aria-label?
Then make two changes and re-render. First, add a second navigation block to the
header area with no label. Second, change the group that is your main
element to a div. After each change, say which of the four answers moved
and whether an automated checker would have caught it.
Show the solution and the reasoning
The four commands that answer the questions against a local site are worth keeping, because they are faster than reading templates and they measure what the user gets:
curl -s http://localhost:8966/a-post-with-a-quote/ \
| grep -oE '<h[1-6][^>]*>' \
| sed 's/<h/@h/'
curl -s http://localhost:8966/a-post-with-a-quote/ \
| grep -oE '<(main|nav|header|footer|aside)[^>]*>'
curl -s http://localhost:8966/a-post-with-a-quote/ \
| grep -o '<a class="skip-link[^>]*>'
curl -s http://localhost:8966/a-post-with-a-quote/ \
| grep -oE '<nav[^>]*>' | grep -c 'aria-label'
Adding an unlabelled navigation block is a change an automated checker will usually
flag, because "two navigation landmarks, one unnamed" is a rule about roles and names. It
is also the easiest of the four to ship by accident, since a navigation block dragged in
from the inserter has no ariaLabel until someone types one. The fix is either
to name every navigation block in the parts and patterns you ship, or to accept a single
navigation landmark per page and merge the footer links into it by convention.
Changing main to a div is the case no automated checker
catches, because the resulting document is valid and has one fewer landmark. The skip
link is what makes it visible: grep for skip-link returns nothing,
and the stylesheet for the link is still in the page. The reasoning to carry forward is that
a block theme's accessibility contract is written in the template file in markup a site owner
can edit, so ship a template whose structural elements are correct and re-check them on the
rendered page rather than in the editor.
One thing not to conclude from any of this: adding ARIA is almost never the right fix. Both changes above are fixed by rendering a missing semantic element or supplying a missing name, not by describing a div.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.