From craft-workspace-webconsulting-skills
Audits and implements WCAG 2.2 AA accessibility in TYPO3 v14 using Fluid templates, PHP middleware, JavaScript enhancements, content elements, forms, and go-live checklist.
npx claudepluginhub dirnbauer/webconsulting-skillsThis skill uses the workspace's default tool permissions.
> **Compatibility:** TYPO3 **v14.x only**. Older cores are out of scope for this collection.
Produces, audits, and overhauls TYPO3 Content Blocks content elements styled with shadcn/ui presets using Fluid templates, Tailwind v4 tokens, backend previews, and seed scripts.
Implements a11y best practices like semantic HTML, keyboard navigation, ARIA attributes, landmarks, focus management, and WCAG 2.1 AA compliance for UI building and audits.
Implements WCAG web accessibility (POUR principles) for UI components, forms, navigation, and multimedia. Use when designing, reviewing, or refactoring web interfaces for compliance.
Share bugs, ideas, or general feedback.
Compatibility: TYPO3 v14.x only. Older cores are out of scope for this collection.
TYPO3 API First: Always use TYPO3's built-in APIs, Fluid ViewHelpers, and core features before adding custom markup. Verify methods exist in TYPO3 v14.
PHP & JS over TypoScript: This skill provides PHP middleware, Fluid partials, and vanilla JavaScript solutions. TypoScript examples are avoided; use PHP-based approaches.
Run through this checklist before every deployment. Mark items as you fix them.
<h1>h1 > h2 > h3, no skips)<main>, <nav>, <header>, <footer>, <aside><html lang="..."> matches page languagelang attribute on containing element<img> have alt attribute (empty alt="" for decorative)<img> have explicit width and height (prevents CLS):focus-visible)<label> (explicit for/id or wrapping)required attributearia-describedby + aria-invalid<fieldset> + <legend>type and autocomplete attributes on inputsaria-labelaria-hidden="true"aria-live="polite" or role="status"role="alert"aria-current="page"aria-expandedprefers-reduced-motion respected (CSS and JS)user-scalable=no is NOT set in viewport metaheader_layout = 100 for hidden)<title> and description<f:link.page> / <f:link.typolink> (not <a> with manual hrefs)<!-- EXT:site_package/Resources/Private/Layouts/Default.html -->
<!-- Layout files must NOT contain <f:layout> — that tag belongs in Templates to select a layout. -->
<f:render section="Main" />
<!-- EXT:site_package/Resources/Private/Templates/Default.html -->
<f:layout name="Default" />
<f:section name="Main">
<a href="#main-content" class="skip-link">
<f:translate key="LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:skip_to_content" />
</a>
<header>
<!-- Main nav landmark lives in the partial (e.g. MainMenu.html) to avoid nested <nav>. -->
<f:cObject typoscriptObjectPath="lib.mainNavigation" />
</header>
<main id="main-content">
<f:render section="Content" />
</main>
<footer>
<nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.footer')}">
<f:cObject typoscriptObjectPath="lib.footerNavigation" />
</nav>
</footer>
</f:section>
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 10000;
padding: 0.75rem 1.5rem;
background: var(--color-primary, #2563eb);
color: var(--color-on-primary, #fff);
font-weight: 600;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
<!-- Partial: Resources/Private/Partials/Media/Image.html -->
<f:if condition="{image}">
<figure>
<f:image
image="{image}"
alt="{image.alternative}"
title="{image.title}"
width="{dimensions.width}"
height="{dimensions.height}"
loading="{f:if(condition: lazyLoad, then: 'lazy', else: 'eager')}"
additionalAttributes="{decoding: 'async'}"
/>
<f:if condition="{image.description}">
<figcaption>{image.description}</figcaption>
</f:if>
</figure>
</f:if>
For decorative images (no informational value):
<f:image image="{image}" alt="" />
<!-- Partial: Resources/Private/Partials/ContentElement/Header.html -->
<f:if condition="{data.header} && {data.header_layout} != '100'">
<f:switch expression="{data.header_layout}">
<f:case value="1"><h1>{data.header}</h1></f:case>
<f:case value="2"><h2>{data.header}</h2></f:case>
<f:case value="3"><h3>{data.header}</h3></f:case>
<f:case value="4"><h4>{data.header}</h4></f:case>
<f:case value="5"><h5>{data.header}</h5></f:case>
<f:defaultCase><h2>{data.header}</h2></f:defaultCase>
</f:switch>
</f:if>
<!-- Partial: Resources/Private/Partials/Navigation/MainMenu.html -->
<nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.main')}">
<!-- role="list" restores list semantics in Safari/VoiceOver when list-style:none strips native list role -->
<ul role="list">
<f:for each="{menu}" as="item">
<li>
<f:if condition="{item.active}">
<f:then>
<a href="{item.link}" aria-current="page">{item.title}</a>
</f:then>
<f:else>
<a href="{item.link}">{item.title}</a>
</f:else>
</f:if>
<f:if condition="{item.children}">
<ul>
<f:for each="{item.children}" as="child">
<li>
<a href="{child.link}"
{f:if(condition: child.active, then: 'aria-current="page"')}>
{child.title}
</a>
</li>
</f:for>
</ul>
</f:if>
</li>
</f:for>
</ul>
</nav>
<div class="accordion" data-accordion>
<f:for each="{items}" as="item" iteration="iter">
<div class="accordion__item">
<!-- Use role="heading" + aria-level so level matches page outline (pass headingLevel 2–6 from CE). -->
<div role="heading" aria-level="{f:if(condition: headingLevel, then: headingLevel, else: '3')}">
<button
type="button"
class="accordion__trigger"
aria-expanded="false"
aria-controls="accordion-panel-{data.uid}-{iter.index}"
id="accordion-header-{data.uid}-{iter.index}"
data-accordion-trigger
>
{item.header}
</button>
</div>
<div
id="accordion-panel-{data.uid}-{iter.index}"
role="region"
aria-labelledby="accordion-header-{data.uid}-{iter.index}"
class="accordion__panel"
hidden
>
<f:format.html>{item.bodytext}</f:format.html>
</div>
</div>
</f:for>
</div>
<div class="tabs" data-tabs>
<div role="tablist" aria-label="{f:translate(key: 'tabs.label')}">
<f:for each="{items}" as="item" iteration="iter">
<button
type="button"
role="tab"
id="tab-{data.uid}-{iter.index}"
aria-controls="tabpanel-{data.uid}-{iter.index}"
aria-selected="{f:if(condition: iter.isFirst, then: 'true', else: 'false')}"
tabindex="{f:if(condition: iter.isFirst, then: '0', else: '-1')}"
>
{item.header}
</button>
</f:for>
</div>
<f:for each="{items}" as="item" iteration="iter">
<div
role="tabpanel"
id="tabpanel-{data.uid}-{iter.index}"
aria-labelledby="tab-{data.uid}-{iter.index}"
tabindex="0"
{f:if(condition: iter.isFirst, then: '', else: 'hidden')}
>
<f:format.html>{item.bodytext}</f:format.html>
</div>
</f:for>
</div>
Ensures <html lang="..."> is always correct based on site language config.
<?php
declare(strict_types=1);
namespace Vendor\SitePackage\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
final class AccessibilityLangMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$siteLanguage = $request->getAttribute('language');
if (!$siteLanguage instanceof SiteLanguage) {
return $response;
}
$contentType = $response->getHeaderLine('Content-Type');
if (!str_contains($contentType, 'text/html')) {
return $response;
}
$locale = $siteLanguage->getLocale();
$langCode = $locale->getLanguageCode();
$body = (string)$response->getBody();
$body = preg_replace(
'/<html([^>]*)lang="[^"]*"/',
'<html$1lang="' . htmlspecialchars($langCode) . '"',
$body,
1
);
$newBody = new \TYPO3\CMS\Core\Http\Stream('php://temp', 'rw');
$newBody->write($body);
return $response->withBody($newBody);
}
}
Register via Configuration/RequestMiddlewares.php (TYPO3 v14; same registration file exists on supported older majors if you maintain multi-version code):
<?php
// Configuration/RequestMiddlewares.php
return [
'frontend' => [
'vendor/site-package/accessibility-lang' => [
'target' => \Vendor\SitePackage\Middleware\AccessibilityLangMiddleware::class,
'after' => ['typo3/cms-frontend/content-length-headers'],
],
],
];
Warn editors when images lack alt text. Use a processDatamapClass hook on sys_file_metadata saves — not workspace publish events (AfterRecordPublishedEvent lives under Workspaces and fires on publish workflows).
<?php
declare(strict_types=1);
namespace Vendor\SitePackage\Hooks;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
final class ImageAltTextValidationHook
{
public function processDatamap_afterDatabaseOperations(
string $status,
string $table,
string|int $id,
array $fieldArray,
DataHandler $dataHandler,
): void {
if ($table !== 'sys_file_metadata') {
return;
}
if (empty($fieldArray['alternative'] ?? '')) {
$message = GeneralUtility::makeInstance(
FlashMessage::class,
'Image is missing alt text. Please add descriptive alt text for accessibility (WCAG 1.1.1).',
'Accessibility Warning',
ContextualFeedbackSeverity::WARNING,
true,
);
GeneralUtility::makeInstance(FlashMessageService::class)
->getMessageQueueByIdentifier()
->addMessage($message);
}
}
}
Register in ext_localconf.php:
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][]
= \Vendor\SitePackage\Hooks\ImageAltTextValidationHook::class;
Renders screen-reader-only text:
<?php
declare(strict_types=1);
namespace Vendor\SitePackage\ViewHelpers;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper;
final class SrOnlyViewHelper extends AbstractTagBasedViewHelper
{
protected $tagName = 'span';
// Fluid 5.x / TYPO3 v14: `registerUniversalTagAttributes()` and `registerTagAttribute()` were
// removed from AbstractTagBasedViewHelper. Register explicit arguments via `registerArgument()`
// in `initializeArguments()`; unregistered attributes pass through via handleAdditionalArguments().
public function render(): string
{
$this->tag->addAttribute('class', 'sr-only');
$this->tag->setContent($this->renderChildren());
return $this->tag->render();
}
}
/* CSS for sr-only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
Usage in Fluid:
{namespace sp = Vendor\SitePackage\ViewHelpers}
<button aria-label="{f:translate(key: 'button.close')}">
<svg aria-hidden="true"><!-- icon --></svg>
<sp:srOnly><f:translate key="button.close" /></sp:srOnly>
</button>
// EXT:site_package/Resources/Public/JavaScript/accordion.js
class AccessibleAccordion {
constructor(container) {
this.container = container;
this.triggers = container.querySelectorAll('[data-accordion-trigger]');
this.init();
}
init() {
this.triggers.forEach((trigger) => {
trigger.addEventListener('click', () => this.toggle(trigger));
trigger.addEventListener('keydown', (e) => this.handleKeydown(e));
});
}
toggle(trigger) {
const expanded = trigger.getAttribute('aria-expanded') === 'true';
const panelId = trigger.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
trigger.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
}
handleKeydown(e) {
const triggers = [...this.triggers];
const index = triggers.indexOf(e.currentTarget);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
triggers[(index + 1) % triggers.length].focus();
break;
case 'ArrowUp':
e.preventDefault();
triggers[(index - 1 + triggers.length) % triggers.length].focus();
break;
case 'Home':
e.preventDefault();
triggers[0].focus();
break;
case 'End':
e.preventDefault();
triggers[triggers.length - 1].focus();
break;
}
}
}
document.querySelectorAll('[data-accordion]').forEach((el) => new AccessibleAccordion(el));
// EXT:site_package/Resources/Public/JavaScript/tabs.js
class AccessibleTabs {
constructor(container) {
this.tablist = container.querySelector('[role="tablist"]');
this.tabs = [...this.tablist.querySelectorAll('[role="tab"]')];
this.panels = this.tabs.map(
(tab) => document.getElementById(tab.getAttribute('aria-controls'))
);
this.init();
}
init() {
this.tabs.forEach((tab) => {
tab.addEventListener('click', () => this.selectTab(tab));
tab.addEventListener('keydown', (e) => this.handleKeydown(e));
});
}
selectTab(selectedTab) {
this.tabs.forEach((tab, i) => {
const selected = tab === selectedTab;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
this.panels[i].hidden = !selected;
});
selectedTab.focus();
}
handleKeydown(e) {
const index = this.tabs.indexOf(e.currentTarget);
let next;
switch (e.key) {
case 'ArrowRight':
next = (index + 1) % this.tabs.length;
break;
case 'ArrowLeft':
next = (index - 1 + this.tabs.length) % this.tabs.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = this.tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
this.selectTab(this.tabs[next]);
}
}
document.querySelectorAll('[data-tabs]').forEach((el) => new AccessibleTabs(el));
// EXT:site_package/Resources/Public/JavaScript/dialog.js
class AccessibleDialog {
constructor(dialog) {
this.dialog = dialog;
this.previousFocus = null;
this.init();
}
init() {
// showModal() closes on Escape natively; listen to 'close' for focus restore only.
this.dialog.addEventListener('close', () => {
this.previousFocus?.focus();
});
this.dialog.addEventListener('click', (e) => {
if (e.target === this.dialog) this.dialog.close();
});
}
open() {
this.previousFocus = document.activeElement;
// showModal() provides built-in focus trapping in supporting browsers; skip custom Tab loops unless you must target legacy engines.
this.dialog.showModal();
}
close() {
this.dialog.close();
}
}
Use the native <dialog> element in Fluid:
<dialog id="my-dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">{dialogTitle}</h2>
<div>{dialogContent}</div>
<button type="button" data-close-dialog>
<f:translate key="button.close" />
</button>
</dialog>
// EXT:site_package/Resources/Public/JavaScript/motion.js
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function getTransitionDuration() {
return prefersReducedMotion.matches ? 0 : 300;
}
prefersReducedMotion.addEventListener('change', () => {
document.documentElement.classList.toggle('reduce-motion', prefersReducedMotion.matches);
});
if (prefersReducedMotion.matches) {
document.documentElement.classList.add('reduce-motion');
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// EXT:site_package/Resources/Public/JavaScript/announcer.js
class LiveAnnouncer {
constructor() {
this.region = document.createElement('div');
this.region.setAttribute('aria-live', 'polite');
this.region.setAttribute('aria-atomic', 'true');
this.region.classList.add('sr-only');
document.body.appendChild(this.region);
}
announce(message, priority = 'polite') {
this.region.setAttribute('aria-live', priority);
this.region.textContent = '';
requestAnimationFrame(() => {
this.region.textContent = message;
});
}
}
window.liveAnnouncer = new LiveAnnouncer();
Usage: window.liveAnnouncer.announce('3 results found');
/* EXT:site_package/Resources/Public/Css/accessibility.css */
/* Visible focus indicator */
:focus-visible {
outline: 3px solid var(--focus-color, #2563eb);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* Touch targets: WCAG 2.2 AA 2.5.8 minimum = 24×24 CSS px (baseline below). */
button, a, input, select, textarea, [role="button"] {
min-height: 24px;
min-width: 24px;
}
/* Optional comfort target — intentionally stricter than AA; common for mobile tap areas */
.touch-target-comfort {
min-height: 44px;
min-width: 44px;
}
/* Good default; test with user-overridden spacing per WCAG 1.4.12 */
body {
line-height: 1.6;
}
p + p {
margin-top: 1em;
}
/* Max line length for readability */
.ce-bodytext, .frame-default .content {
max-width: 75ch;
}
/* Scroll margin for anchored headings */
[id] {
scroll-margin-top: 5rem;
}
/* Hover + focus parity */
a:hover, a:focus-visible,
button:hover, button:focus-visible {
text-decoration: underline;
}
Make the alt text field required in file metadata:
<?php
// Configuration/TCA/Overrides/sys_file_metadata.php
$GLOBALS['TCA']['sys_file_metadata']['columns']['alternative']['config']['required'] = true;
// On v14 prefer `required` (above). Avoid legacy `eval` = required patterns.
Provide proper heading levels and a "hidden" option for content elements:
<?php
// Configuration/TCA/Overrides/tt_content.php
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['items'] = [
['label' => 'H1', 'value' => '1'],
['label' => 'H2', 'value' => '2'],
['label' => 'H3', 'value' => '3'],
['label' => 'H4', 'value' => '4'],
['label' => 'H5', 'value' => '5'],
['label' => 'Hidden', 'value' => '100'],
];
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['default'] = '2';
Legacy TCA items shape (pre–label/value arrays): numeric keys [0] = label, [1] = value — avoid on v14-only projects; kept here only for reading older extensions:
// Legacy style (do not use for new v14-only TCA)
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['items'] = [
['H1', '1'],
['H2', '2'],
['H3', '3'],
['H4', '4'],
['H5', '5'],
['Hidden', '100'],
];
# EXT:site_package/Configuration/Form/Overrides/AccessibleForm.yaml
TYPO3:
CMS:
Form:
prototypes:
standard:
formElementsDefinition:
Text:
properties:
fluidAdditionalAttributes:
autocomplete: 'on'
Email:
properties:
fluidAdditionalAttributes:
autocomplete: 'email'
inputmode: 'email'
Telephone:
properties:
fluidAdditionalAttributes:
autocomplete: 'tel'
inputmode: 'tel'
If CSP is enabled, ensure focus styles via external CSS (not inline):
<?php
// No inline styles needed — use the external accessibility.css
// If you must add inline styles, extend CSP via PSR-14:
declare(strict_types=1);
namespace Vendor\SitePackage\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent;
#[AsEventListener(identifier: 'vendor/site-package/csp-accessibility')]
final class CspAccessibilityListener
{
public function __invoke(PolicyMutatedEvent $event): void
{
if ($event->scope->type->isFrontend()) {
// Prefer external CSS over extending CSP for inline styles
}
}
}
| Tool | Use |
|---|---|
axe-core / axe DevTools | Browser extension for automated WCAG checks |
pa11y | CLI accessibility testing for CI/CD |
Lighthouse | Chrome DevTools audit (Accessibility score) |
WAVE | Browser extension for visual feedback |
prefers-reduced-motion: reduce in OS settings# Run against local DDEV instance
npx pa11y https://mysite.ddev.site --standard WCAG2AA --reporter cli
// .pa11yci.json
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 30000,
"wait": 2000
},
"urls": [
"https://mysite.ddev.site/",
"https://mysite.ddev.site/contact",
"https://mysite.ddev.site/news"
]
}
| Extension | Purpose | TYPO3 v14 |
|---|---|---|
typo3/cms-core | Built-in ARIA, focus management | ✓ |
brotkrueml/schema | Structured data (WebPage, Article) | ✓ (verify Packagist) |
| (no stable Packagist/TER listing verified) | Third-party accessibility toolbars change often — evaluate extensions individually before recommending | — |
b13/container | Accessible grid/container layouts | ✓ (verify Packagist) |
| Success Criterion | Level | TYPO3 Solution |
|---|---|---|
| 1.1.1 Non-text Content | A | alt attribute on <f:image>, TCA required field |
| 1.3.1 Info and Relationships | A | Semantic HTML in Fluid, <fieldset>/<legend> |
| 1.3.5 Identify Input Purpose | AA | autocomplete attributes on form fields |
| 1.4.1 Use of Color | A | Icons + text alongside color indicators |
| 1.4.3 Contrast (Minimum) | AA | CSS variables with 4.5:1 ratio |
| 1.4.4 Resize Text | AA | rem units, no user-scalable=no |
| 1.4.10 Reflow | AA | Fluid/responsive layouts |
| 1.4.11 Non-text Contrast | AA | 3:1 for UI components |
| 1.4.12 Text Spacing | AA | line-height: 1.6, flexible containers |
| 2.1.1 Keyboard | A | Native elements, keyboard handlers |
| 2.4.1 Bypass Blocks | A | Skip link |
| 2.4.3 Focus Order | A | Logical DOM order |
| 2.4.7 Focus Visible | AA | :focus-visible styles |
| 2.4.11 Focus Not Obscured | AA | scroll-margin-top, no sticky overlaps |
| 2.5.8 Target Size (Minimum) | AA | 24×24 CSS px minimum; larger targets improve usability |
| 3.1.1 Language of Page | A | <html lang> via middleware |
| 3.1.2 Language of Parts | AA | lang attribute on multilingual content |
| 3.3.1 Error Identification | A | aria-invalid, aria-describedby |
| 3.3.2 Labels or Instructions | A | <label> on every input |
| 4.1.2 Name, Role, Value | A | ARIA attributes on custom widgets |
| 4.1.3 Status Messages | AA | aria-live regions |
| Anti-Pattern | Fix |
|---|---|
<div onclick="..."> | Use <button> |
outline: none without replacement | Use :focus-visible with custom outline |
placeholder as sole label | Add <label> element |
tabindex > 0 | Use tabindex="0" or natural DOM order |
user-scalable=no in viewport | Remove it |
font-size: 12px (absolute) | Use rem units |
Images without alt | Add alt="..." or alt="" for decorative |
| Color-only error indication | Add icon + text |
<a> without href for action | Use <button> |
Missing lang on <html> | Set via middleware or Fluid layout |
| Auto-playing video with sound | Add muted or remove autoplay |
| Skipped heading levels | Fix hierarchy |
The following accessibility-related changes apply exclusively to TYPO3 v14.
TYPO3 v14 introduces a new accessible combobox pattern (Forge #106637; do not confuse with unrelated Gerrit change IDs) in the backend. When building custom backend UI widgets, use this pattern instead of custom autocomplete/dropdown implementations.
<dialog> Element [v14 only]Backend modals migrated from Bootstrap Modal to native <dialog> element (Forge #107443). The native dialog provides better accessibility out of the box:
aria-modal semanticsEscape key handlingUpdate custom backend JavaScript that listens to Bootstrap modal events such as show.bs.modal / hidden.bs.modal — TYPO3 v14 uses events like typo3-modal-show, typo3-modal-shown, typo3-modal-hide, and typo3-modal-hidden. Also replace Bootstrap modal triggers such as data-bs-toggle="modal" with TYPO3's t3js-modal-trigger class where appropriate.
Fluid 5.x is stricter about ViewHelper arguments in some setups. Prefer correct types for numeric and boolean attributes (tabindex, aria-expanded, etc.) and validate templates after upgrades.
Accessibility patterns adapted from: