Accessibility and Performance in Themes
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
A theme controls most of the markup, styling and scripting a visitor meets, so it decides much of whether a Drupal site
can be used with a keyboard, a screen reader or zoomed text, and much of how quickly the first screen appears. This page
covers both halves for the Meridian Museum theme meridian: what core promises about accessibility, the markup and
JavaScript APIs a theme uses to keep that promise (skip links, focus styles, Drupal.announce and the tabbing
manager), the editor-side checker that ships with Drupal CMS, and then the performance work a theme owns (Core Web
Vitals, font loading, image formats, lazy loading, critical CSS and Lighthouse).
This page was written against Drupal 11.4.x, Drush 13/14 and PHP 8.3 or later, and checked on 2026-10-30. Drupal 10 ends on 2026-12-09, so new themes should target Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07; it removes Olivero, Claro and Stable 9 from core, which matters for anything below that copies markup from those themes.
General accessibility principles, WCAG conformance levels and legal requirements are explained in Web Accessibility; the browser-side performance fundamentals are in Performance: Loading and Native HTML Images and Lazy Loading. This page applies them to Drupal themes. The theme files themselves are introduced in Theme Info and Regions and Libraries and Assets.
What core commits to
Drupal core "targets the Web Content Accessibility Guidelines (WCAG) 2.2 at level AA", for both the public-facing site and the administrative interface, according to the project’s accessibility statement. Older tutorials and drupal.org nodes still say 2.1 AA; the current statement is 2.2 AA. Two consequences matter to a themer:
-
Significant core changes pass an accessibility gate. The gate blocks changes that carry serious accessibility problems. The statement is explicit that passing the gate is a safeguard, not a complete conformance assessment, so core themes are a good starting point but not a certificate for the finished site.
-
Contributed modules and custom themes are outside the gate. Accessibility of the
meridiantheme, of its components and of every contributed block or widget depends on the project. A site’s own conformance claim has to be tested on the site.
Core’s front-end tooling reflects the target. Core ships ARIA-aware JavaScript helpers (the two covered below),
the visually-hidden and focusable utility classes, and Olivero’s accessible markup patterns. Core’s own accessibility
checks run in its automated test suite; a theme can run the same engine, axe-core, in its own pipeline (see
Testing in the pipeline).
Source: Drupal.org: Accessibility
Where the roles meet
| Role | Accessibility responsibility |
|---|---|
Themer |
Landmarks, skip links, focus styles, colour contrast, component markup, keyboard operation of scripted widgets |
Site builder |
Heading order in templates and Views, alt text fields required on image fields, text formats that keep tables and headings valid |
Content editor |
Alt text, link text, heading order, captions, transcripts, usually helped by Editoria11y |
Developer |
ARIA live announcements, focus management in AJAX and dialogs, accessible form errors |
Skip links
A skip link is the first focusable element on the page. It lets a keyboard user jump past the header and navigation to
the content. Olivero’s html.html.twig renders it directly after the opening of the body, before page_top:
<a href="#main-content" class="visually-hidden focusable skip-link">
{{ 'Skip to main content'|t }}
</a>
Its target is an anchor in page.html.twig, inside the main content region:
<div class="main-content">
<a id="main-content" tabindex="-1"></a>
{{ page.content }}
</div>
The pieces fit together as follows:
-
visually-hiddenremoves the link from the visual layout while leaving it in the accessibility tree, andfocusablemakes the same element visible again while it has keyboard focus. Together they give the classic "appears only when tabbed to" behaviour. Olivero then positions and styles the focused link incss/components/skip-link.css. -
tabindex="-1"on the target anchor makes it programmatically focusable, so that activating the link moves keyboard focus to that position and not only the scroll position. Without it some browsers scroll but leave focus at the link, and the next Tab press returns to the header. -
The visible text goes through
|t, so it is translated with the rest of the interface (see Multilingual).
The commonest defects are all in the theme: deleting the main-content anchor from page.html.twig because it looks
empty (the skip link then points nowhere), moving the main content into a different region so the anchor is no longer
immediately before it, giving the focused link display: none-style hiding that also hides it from keyboard users, and
removing the focus outline without replacing it. A theme generated with Starterkit (see
Starterkit and Subthemes) copies the starter theme’s own templates; check
that both halves of the skip link survive any template override. Because Olivero leaves core in Drupal 12, a theme must
carry its own copy of the pattern rather than depend on Olivero’s templates or CSS.
For a page with several landmark groups, a site may offer more than one skip link, for example "Skip to main content"
and "Skip to search". Each target needs its own id and tabindex="-1".
A meridian variant with two skip links:
{# templates/layout/html.html.twig (extract) #}
<a href="#main-content" class="visually-hidden focusable skip-link">
{{ 'Skip to main content'|t }}
</a>
<a href="#museum-search" class="visually-hidden focusable skip-link">
{{ 'Skip to collection search'|t }}
</a>
Source: Twig in Drupal and the Olivero templates in Drupal core: Olivero layout templates
Focus styles and keyboard operation
WCAG 2.2 AA has two criteria that bear directly on focus styling: a visible keyboard focus indicator must exist (success criterion 2.4.7), and a focused element must not be entirely hidden by author-created content such as a sticky header (2.4.11, Focus Not Obscured, new in 2.2). Target size (2.5.8, 24 by 24 CSS pixels at minimum) and dragging alternatives (2.5.7) are also new in 2.2 and affect small icon buttons and drag handles in themes.
/* css/base/focus.css in the meridian theme */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 3px solid var(--meridian-focus, #1a4fd6);
outline-offset: 2px;
}
/* Keep focused items clear of the sticky header (WCAG 2.2, 2.4.11). */
:target,
[tabindex="-1"]:focus {
scroll-margin-block-start: 5rem;
}
@media (forced-colors: active) {
:focus-visible {
outline-color: Highlight;
}
}
:focus-visible shows the indicator for keyboard focus and hides it for pointer clicks on most elements. Never write
outline: none on :focus without a replacement, and test windows high-contrast or forced-colours mode, where
background colours disappear and only outlines and borders remain. The contrast of the indicator against its
surroundings should be at least 3:1.
Attaching the stylesheet goes through the theme’s library, as for any asset:
# meridian.libraries.yml
global-styling:
version: VERSION
css:
base:
css/base/focus.css: {}
theme:
css/components/skip-link.css: {}
Scripted widgets need the same care. A custom exhibition filter or a gallery lightbox built from a div must provide a
role, a name, keyboard operation and a managed focus; native elements (button, details, dialog) give most of that
for free and are preferable. Single Directory Components (see
Single Directory Components) are a good unit for this: the component
template holds the semantics, and its .js file attaches the behaviour.
Announcing changes: Drupal.announce
When JavaScript changes the page without a navigation, a sighted user sees the change and a screen reader user does not.
Drupal.announce(text, priority), defined in core/misc/announce.js, writes the text into an ARIA live region so that
assistive technology reads it.
How it works inside core:
-
A behavior named
drupalAnnouncecreates onedivwith the iddrupal-live-announce, the classvisually-hidden,aria-live="polite"andaria-busy="false", and appends it to the body. -
Drupal.announce()pushes the text and priority onto an internal queue and then calls a debounced function with a 200 ms delay. Several announcements made in quick succession are joined, separated by newlines, and written as one text node, because a live region reads only the text that is present when it is evaluated. -
If any queued announcement has the priority
'assertive', the whole group is announced as assertive; otherwise the default is'polite'. Assertive announcements interrupt the screen reader, so they are for errors and urgent state. -
The live region is cleared before each write so that a repeated string is still read.
-
The text should already be translated: pass it through
Drupal.t()first.
A theme or module declares the dependency in its library:
# meridian.libraries.yml
exhibition-filter:
version: VERSION
js:
js/exhibition-filter.js: {}
dependencies:
- core/drupal
- core/once
- core/drupal.announce
- core/drupal.debounce
and uses it in a behavior. The exhibition filter below shows or hides teasers and reports the result count:
/**
* @file
* Filters exhibition teasers on the Meridian "What's on" page.
*/
((Drupal, once) => {
Drupal.behaviors.meridianExhibitionFilter = {
attach(context) {
once('meridian-filter', '[data-meridian-filter]', context).forEach((select) => {
const list = document.querySelector('[data-meridian-exhibitions]');
select.addEventListener('change', () => {
let shown = 0;
list.querySelectorAll('[data-gallery]').forEach((item) => {
const match = !select.value || item.dataset.gallery === select.value;
item.hidden = !match;
if (match) {
shown += 1;
}
});
Drupal.announce(
Drupal.formatPlural(
shown,
'1 exhibition shown',
'@count exhibitions shown',
),
);
});
});
},
};
})(Drupal, once);
Two pitfalls recur. Announcing on every keystroke floods the user; announce the settled result (the debounce helps but does not replace a considered design). And an announcement does not move focus: when a dialog opens or content is replaced, focus must still be placed deliberately, which is what the tabbing manager and the Drupal Ajax framework help with. For Ajax in particular, see AJAX and HTMX.
Constraining focus: the tabbing manager
Drupal.tabbingManager, defined in core/misc/tabbingmanager.js and available through the library
core/drupal.tabbingmanager, manages modifications to the page’s tabbing order. It is a stack of "tabbing contexts".
Only the context at the top of the stack is active: the elements it lists are reachable by the Tab key, and the elements
it disables are not. The library depends on core/jquery, core/drupal and core/tabbable (the tabbable package
computes which elements can currently receive focus), so this part of the API still takes jQuery objects, selectors or
elements.
| API | Purpose |
|---|---|
|
Pushes a new context that makes only |
|
Marks the context released and returns to the previous one. A released context can never be activated again. |
|
Events that other scripts can listen for. |
Core uses it for the Off-canvas tray, the contextual toolbar and the toolbar. A custom overlay, such as the Meridian gallery lightbox, uses it the same way:
((Drupal, once) => {
Drupal.behaviors.meridianLightbox = {
attach(context) {
once('meridian-lightbox', '[data-meridian-lightbox-open]', context).forEach((button) => {
button.addEventListener('click', () => {
const dialog = document.getElementById(button.getAttribute('aria-controls'));
dialog.hidden = false;
const tabbingContext = Drupal.tabbingManager.constrain(dialog, { trapFocus: true });
dialog.querySelector('[data-meridian-lightbox-close]').focus();
Drupal.announce(Drupal.t('Gallery opened'));
dialog.querySelector('[data-meridian-lightbox-close]').addEventListener('click', () => {
dialog.hidden = true;
tabbingContext.release();
button.focus();
}, { once: true });
});
});
},
};
})(Drupal, once);
The three habits that make this accessible are: move focus into the dialog when it opens, release the context and return
focus to the opener when it closes, and close on the Escape key. The native <dialog> element opened with
showModal() provides the first and last of these without any Drupal code and is the better choice for new components;
the tabbing manager is most useful when integrating with core UI that already uses it.
Editoria11y in Drupal CMS
Themes make accessible markup possible, but editors decide most of the accessibility of the content. Drupal CMS includes
the contributed Editoria11y module (an open-source, multilingual accessibility checker) as part of its "Accessibility
Tools" add-on; the recipe that carries it installs the editoria11y and views modules and is named Accessibility
Tools. According to the Drupal CMS documentation:
-
An administrator installs the Accessibility Tools add-on from Extend > Recommended add-ons (
/admin/modules/browse/recommended). -
A small widget then appears on content editing forms and on content pages. A blue check means no issues were found; a red flag shows the number of issues or warnings.
-
The sitewide Content Accessibility report at Reports > Content Accessibility (
/admin/reports/editoria11y) lists issues across the site’s content. -
It flags missing or unclear alternative text, skipped or empty headings, unhelpful link text, tables without headers and possible low colour contrast. Some results are warnings that need human review, for example embedded video.
Permissions are separate from the roles in the Meridian scenario: the module provides View Editoria11y Checker, plus permissions for the "ignore" and "mark OK" buttons and for the dashboard reports. It tries to grant the checker to roles with common node edit permissions by default; the Content Editor role is therefore covered, and the Curator and Reviewer roles should be checked on the permissions page (see Users, Roles and Permissions). Editoria11y 3.x supports Drupal 10.3, 11 and 12 (3.0.11 was the current stable release on 2026-10-09); the 2.2.x line supports Drupal 9, 10 and 11.
Drupal CMS is a distribution of recipes, so on a Drupal CMS 2.x site the checker sits beside Canvas, the default editor (see Canvas Visual Page Building and Drupal CMS and Site Templates). On a plain Drupal 11 site the same module is installed with Composer:
composer require drupal/editoria11y
ddev drush en editoria11y -y
ddev drush cr
Two limits are worth stating plainly. An automated checker finds only the problems that can be detected from markup; it cannot judge whether alt text is accurate or a transcript is correct. And it checks rendered content, so a theme that hides focus outlines or sets low-contrast colours is outside what the editor tools can fix. The AI-generated alt text feature of the AI module (see AI Module and Providers) is a drafting aid that an editor still reviews.
Testing in the pipeline
Manual keyboard and screen reader checks stay necessary, but regressions in a theme are cheaper to catch in continuous integration. A theme repository can run axe-core, or Lighthouse’s accessibility category, against the rendered pages of a test site. See End-to-End Testing for browser-test tooling and Automated Testing for the Drupal test types.
Performance: what the theme owns
Performance work in Drupal is shared between the server (page and render caching, covered in Performance and Caching) and the front end. A themer cannot make a cold database query faster, but owns the markup, the CSS and JavaScript that are delivered, the fonts and the images, which largely decide the measurements that visitors and search engines see.
Core Web Vitals
Core Web Vitals are three field metrics, assessed at the 75th percentile of real page loads:
| Metric | Measures | "Good" threshold |
|---|---|---|
Largest Contentful Paint (LCP) |
Time until the largest element in the first screen is rendered |
2.5 seconds or less |
Interaction to Next Paint (INP) |
Delay between user input and the next paint, across the visit |
200 milliseconds or less |
Cumulative Layout Shift (CLS) |
Unexpected movement of visible content |
0.1 or less |
INP replaced First Input Delay as the responsiveness metric in March 2024, so articles that discuss FID are out of date. What each metric means for a Drupal theme:
-
LCP is usually a hero image or a heading. For the Meridian exhibition page it is the hero media. The theme should load that image eagerly with the right size, not lazily, and should not delay it behind blocking CSS or a web font.
-
INP suffers from heavy JavaScript. Drupal behaviors that run on every page, large libraries attached globally, and long tasks in event handlers all raise it. Attach libraries only where they are used (per template or per component with
attach_library) and avoid global jQuery plugins. -
CLS comes from images and embeds without dimensions, late-loading fonts that change the text’s size, and banners inserted above existing content. Drupal image fields output
widthandheightattributes, and responsive images keep them, so the browser can reserve space; custom CSS should setaspect-ratiofor embedded video.
Source: web.dev: Web Vitals
Font loading
Fonts are a common cause of both late text rendering and layout shift. The reliable recipe is self-hosted woff2 files,
font-display: swap (or optional), a fallback with similar metrics, and a preload for the one or two files that are
needed on the first screen:
/* css/base/fonts.css in the meridian theme */
@font-face {
font-family: "Meridian Sans";
src: url("../../fonts/meridian-sans-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
body {
font-family: "Meridian Sans", system-ui, sans-serif;
}
From Drupal 11.4 a library definition can declare fonts and ask Drupal to emit the preload link, so no
html_head_alter code is needed. Olivero’s own library uses the same key for its Metropolis fonts:
# meridian.libraries.yml (Drupal 11.4 and later)
global-styling:
version: VERSION
css:
base:
css/base/fonts.css: {}
fonts:
fonts/meridian-sans-regular.woff2:
preload: true
Preload only the fonts that are certain to be used above the fold; preloading everything competes with the LCP image for
bandwidth. Setting preload: false is equivalent to omitting the entry. On Drupal 10.x and 11.0 to 11.3 the fonts key
does not exist, so the preload link is added by hand, either in the html.html.twig template or from an
html_head attachment. Font preloads need the crossorigin attribute even for same-origin files:
<?php
/**
* Implements hook_page_attachments_alter() (Drupal 10.x to 11.3 fallback).
*/
function meridian_page_attachments_alter(array &$attachments): void {
$attachments['#attached']['html_head'][] = [
[
'#tag' => 'link',
'#attributes' => [
'rel' => 'preload',
'href' => '/themes/custom/meridian/fonts/meridian-sans-regular.woff2',
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => 'anonymous',
],
],
'meridian_font_regular',
];
}
Themes can implement hooks as methods with the #[Hook] attribute from Drupal 11.3 (preprocess hooks from 11.2); the
procedural function above is used only because this fallback is for versions that predate that, and only for a site that
cannot yet use 11.4. Older books such as Modernizing Drupal 10 Theme Development (2023) were written for Drupal 10.1 and
show hand-written preload links, which was then the only option; since 11.4 the fonts key replaces them. Fonts served
from a third-party host add a DNS lookup and connection; self-hosting them is also kinder to visitors' privacy.
Image formats and lazy loading
Images are usually the heaviest part of a page. Drupal gives three levers, all configuration rather than code, described in Display Modes and Image Styles and Breakpoints and Responsive Images:
-
Modern formats through image styles. The Image module has a Convert effect that changes the output format. WebP conversion works with the standard GD toolkit. AVIF is supported by the GD toolkit from Drupal 11.2 and depends on the server: PHP’s GD must be built against libgd 2.3.2 or later with libavif, otherwise the option is missing or conversion fails. The "Convert to AVIF" effect (
image_convert_avif) therefore usually sits in a style that falls back to WebP. Check the format list on the image toolkit configuration page (/admin/config/media/image-toolkit) of each environment, since a local DDEV container and production can differ. Drupal 11.2.0 had a fatal error on builds of GD without AVIF encoding; it was fixed in 11.2.x, so run a current 11.4 release. -
Responsive image styles. A responsive image style maps breakpoints to image styles and renders a
pictureelement or asrcsetwithsizes, so a phone downloads a small file. Define one style per layout slot (hero, teaser, gallery thumbnail), not one per page. -
Native lazy loading. The image and responsive image formatters have an Image loading setting with
lazy(the default) andeager. Lazy loading is right for everything below the first screen, and wrong for the LCP image.
Configuration for the Meridian hero, loaded eagerly, and for the gallery thumbnails, lazily, in a view display export:
# config/sync/core.entity_view_display.node.exhibition.default.yml (extract)
content:
field_hero_image:
type: responsive_image
label: hidden
settings:
responsive_image_style: meridian_hero
image_link: ''
image_loading:
attribute: eager
third_party_settings: { }
weight: 0
region: content
field_gallery:
type: responsive_image
label: hidden
settings:
responsive_image_style: meridian_thumbnail
image_link: file
image_loading:
attribute: lazy
third_party_settings: { }
weight: 5
region: content
Alt text belongs to this topic as well: the image field’s Alt field required setting is the site builder’s guard, and
the decorative-image case is handled with an empty alt attribute. A hero used purely as decoration should be
rendered by CSS, not as content.
Critical CSS and asset loading
Render-blocking CSS delays LCP. Drupal’s defaults already help: CSS and JavaScript aggregation (Configuration >
Development > Performance, or $config['system.performance']['css']['preprocess'] in settings.php) combines files
and enables long-lived caching of the aggregates, and the library system loads only the libraries attached to the page.
The themer’s tools beyond that are:
-
Split the CSS by component. In a Single Directory Component, the component’s
.cssis attached only when the component renders, so a page without theexhibition-teasernever downloads its styles. -
Per-template libraries. Attach a heavy library (the map, the lightbox) from the template that needs it with
{{ attach_library('meridian/lightbox') }}. -
JavaScript in the footer, deferred. Libraries load their scripts at the end of the body by default;
header: truemoves one to the head, so reserve it for the rare script that must run early. -
Critical CSS. The approach is to inline the CSS required for the first screen in the
headand load the rest without blocking. Core has no switch for this. A build step (a tool in the project’s front-end pipeline, see Front-End Tooling) extracts the above-the-fold rules for the main templates, and the theme outputs them in astyleelement and loads the full stylesheet withrel="preload"plus anonloadswitch. Inlined CSS defeats browser caching and must be kept small, and every template needs its own critical set, so it is the last optimisation to adopt, and one to measure before and after. For most Drupal sites, aggregation, component-level CSS and eager hero images give the larger part of the gain.
# meridian.libraries.yml
lightbox:
version: VERSION
js:
js/lightbox.js: {}
css:
component:
css/components/lightbox.css: {}
dependencies:
- core/drupal
- core/once
- core/drupal.tabbingmanager
- core/drupal.announce
{# templates/field/field--field-gallery.html.twig (extract) #}
{{ attach_library('meridian/lightbox') }}
<div{{ attributes.addClass('meridian-gallery') }}>
{% for item in items %}
{{ item.content }}
{% endfor %}
</div>
Lighthouse checks
Lighthouse is the audit built into Chromium’s developer tools and available from the command line. It reports a Performance score from lab measurements (including LCP and CLS, plus Total Blocking Time as a proxy for INP, since lab tests cannot measure real interactions) and an Accessibility score from axe-core rules. Both are useful and both are partial:
-
The Accessibility score covers only what can be detected automatically, which is a minority of WCAG criteria. A 100 does not mean the page is accessible, and keyboard, screen reader and zoom testing are still needed.
-
The Performance score is a lab result on one device profile. Real visitors are measured by field data (the Chrome UX Report, or the Core Web Vitals report in Search Console), which can differ widely.
-
Test with Drupal’s caches warmed and as the anonymous user, since logged-in pages bypass the page cache and look slower. Remember that Twig debugging and disabled aggregation in a development
settings.local.phpdistort the numbers (see Twig Templates and Debugging); measure a production-like build.
A repeatable command against the DDEV site, with the result stored as JSON for comparison between commits:
ddev drush cache:rebuild
npx lighthouse@12 https://meridian.ddev.site/exhibitions \
--only-categories=performance,accessibility \
--output=json --output-path=./lighthouse-exhibitions.json \
--chrome-flags="--headless=new --ignore-certificate-errors"
A short checklist for each release of the theme:
-
Keyboard only: reach every control, see the focus ring, skip link works, no keyboard trap, Escape closes overlays.
-
Zoom to 200 percent and 400 percent wide: no loss of content or horizontal scrolling for text content.
-
Screen reader: landmarks and headings make sense, dynamic changes are announced.
-
Editoria11y report is clean for the demo content, and colour contrast of the theme’s palette meets AA.
-
Lighthouse: LCP element is the intended one and is not lazy; no layout shift from fonts or banners; scripts are attached only where used.
Common errors and pitfalls
-
Removing or hiding the skip link target. Deleting the empty
main-contentanchor, or moving the main region, breaks the link silently. -
outline: nonewithout a replacement, or a focus indicator whose contrast is below 3:1. A sticky header can also cover the focused element (WCAG 2.2, 2.4.11). -
Announcing everything, or announcing before translating. Pass strings through
Drupal.t()andDrupal.formatPlural(); keep announcements short and meaningful. -
Constraining tabbing and forgetting to release. An unreleased
TabbingContextleaves the rest of the page unreachable by keyboard until reload. Release in every close path, including error paths. -
Lazy loading the LCP image, the commonest cause of a poor LCP on a Drupal page that otherwise looks fast. Set the hero’s display to
eager. -
Preloading fonts without
crossorigin(hand-written links), so the font downloads twice; preloading too many fonts. -
AVIF without server support. Configure a WebP fallback, and test each environment.
-
Trusting a Lighthouse 100. It is a lab test with automated rules only.
-
Copying Olivero’s CSS and templates verbatim. They leave core in Drupal 12; copy the patterns into the theme’s own files, or start from the Starterkit theme.
Version notes
| Version | Change |
|---|---|
Drupal 10.0 |
Starterkit replaces Classy and Stable as the way to start a theme |
Drupal 10.3 |
Single Directory Components are part of core, so component CSS and JS are attached with the component |
Drupal 11.2 |
GD toolkit supports AVIF; theme preprocess hooks can be OOP |
Drupal 11.3 |
Themes can implement OOP hooks |
Drupal 11.4 |
Library definitions support the |
Drupal 12 (planned week of 2026-12-07) |
Olivero, Claro and Stable 9 are removed from core |