
Typo3 A11y
- 13 installs
- 1 repo stars
- Updated July 18, 2026
- netresearch/typo3-a11y-skill
Helps with ai & agent building tasks.
About
typo3-a11y is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- typo3-a11y
- AI & Agent Building
- AI-coding skill
Typo3 A11y by the numbers
- 13 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #11,409 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/typo3-a11y-skill --skill typo3-a11yAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 18, 2026 |
| Repository | netresearch/typo3-a11y-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
TYPO3 Accessibility Skill
WCAG 2.1 Level AA compliance standards for TYPO3 v13 and v14.3 LTS sitepackage frontend development.
v14 a11y wins (use as reference):
- Native
<dialog>modal replaces Bootstrap Modal (Breaking #107443) — proper focus trap + Esc-to-close for free. - DocHeader breadcrumb rework (Feature #107875) improves landmark structure.
- CKEditor 5 v47 dark/light context-aware (Breaking #106964) respects
prefers-color-scheme. - Camino theme (v14.1+, Feature #108539) is an alternative to
bootstrap-packagewith configurable nav/footer — validate a11y when opting in.
Accessibility Recommendations
These rules should be followed in every TYPO3 sitepackage:
1. Skip links required -- first focusable element on every page 2. Links underlined in body text -- color alone is not sufficient for link identification 3. Never disable buttons -- validate on click, show error messages 4. No `role="menu"` for navigation -- use <nav> > <ul role="list"> > <a> 5. `role="list"` on styled lists -- preserves list semantics when list-style: none is applied 6. Viewport must allow zoom -- never use user-scalable=no or maximum-scale=1 7. `aria-expanded` on disclosure triggers -- toggles must announce their state
Content Element Accessibility Checklist
When creating or reviewing content elements, verify:
- Interactive elements have ARIA attributes (
aria-expanded,aria-controls,aria-label) - Images have
alttext (oralt=""for decorative) - Focus indicators are visible (
:focus-visiblestyles) prefers-reduced-motionis respected for animations- Color contrast meets WCAG AA (4.5:1 text, 3:1 large text/UI)
- Keyboard navigation works (Tab, Escape, Enter, Space)
References
Core
references/accessibility.md-- WCAG 2.1 AA comprehensive guide
Patterns
references/patterns-skiplinks.md-- Mandatory skip link navigationreferences/patterns-accessible-navigation.md-- Navigation, submenus, mobile navreferences/patterns-accessible-forms.md-- Form labels, errors, fieldsets, multi-stepreferences/patterns-accessible-filter.md-- Filtering, pagination, sorting, table accessibilityreferences/patterns-disclosure-widget.md-- Accordions, collapsible sections, hiding techniquesreferences/patterns-clickable-cards.md-- Accessible card/teaser click patternsreferences/patterns-responsive-tables.md-- Mobile table patterns
Recommended Reading
Web Accessibility Cookbook by Manuel Matuzovic (O'Reilly, 2024)
Accessibility Standards
WCAG 2.1 Level AA compliance for TYPO3 sitepackage frontend code.
Table of Contents
1. Language and Page Metadata -- WCAG 3.1.1, 2.4.2 2. Page Structure and Landmarks -- WCAG 1.3.1, 2.4.1 3. Headings and Content Order -- WCAG 2.4.6, 1.3.2 4. Links -- WCAG 2.4.4, 1.4.1 5. Buttons -- WCAG 4.1.2, 1.3.1 6. Color and Contrast -- WCAG 1.4.3 7. CSS Units and User Preferences -- WCAG 1.4.4 8. Focus Management -- WCAG 2.4.7, 2.4.3, 2.1.2 9. ARIA Reference -- WCAG 4.1.2 10. Automated Accessibility Testing 11. Content Element Checklist
Cross-references to dedicated pattern files:
patterns-skiplinks.md-- Skip link navigationpatterns-clickable-cards.md-- Clickable card patternspatterns-accessible-navigation.md-- Navigation patternspatterns-disclosure-widget.md-- Disclosure and accordionpatterns-accessible-forms.md-- Form patternspatterns-accessible-filter.md-- Filtering and tablespatterns-responsive-tables.md-- Mobile table patterns
---
Language and Page Metadata
Natural Language
Set lang on <html> -- affects screen reader pronunciation, hyphenation, quotation marks, and translation tools. In TYPO3, this is handled via site configuration.
<html lang="{siteLanguage.locale.languageCode}">For inline foreign-language text, set lang on the containing element:
<p>The term <span lang="ja-Latn">Kaizen</span> means continuous improvement.</p>Use sparingly -- frequent voice profile switches interrupt reading flow. Well-established loanwords (Download, Workshop, Link) don't need it.
Page Title
Every page must have a unique, descriptive <title>. In TYPO3, this comes from config.pageTitleFirst = 1 and the page title field.
Rules:
- Unique per page -- never the same title on different pages
- Concise -- under 60 characters
- Page name first, then site name:
Products - Shop Name(notShop Name - Products) - Context-dependent information when relevant:
<!-- Checkout step -->
<title>Checkout (step 3 of 4) - Shop Name</title>
<!-- Form errors -->
<title>2 errors - Contact - Site Name</title>
<!-- Search results -->
<title>21 results for "term" - Site Name</title>
<!-- Paginated results -->
<title>Page 2 - Products - Site Name</title>Viewport
Only this viewport meta tag is allowed:
<meta name="viewport" content="width=device-width, initial-scale=1">Never use:
user-scalable=no-- disables zoom for low-vision usersmaximum-scale=1-- disables zoom in some browsers- Fixed width values like
width=500
---
Page Structure and Landmarks
Landmarks
Every page layout must contain these semantic regions:
<header class="main-header" id="main-header"> <!-- banner -->
<nav aria-label="Main navigation"> <!-- navigation -->
<main id="main-content"> <!-- main -->
<aside> <!-- complementary -->
<footer class="main-footer" id="main-footer"> <!-- contentinfo -->Navigation Landmarks
Significant groups of links must be wrapped in <nav> with a label:
<nav aria-label="{f:translate(key: 'mainNavigation', extensionName: 'my_sitepackage')}">
<nav aria-label="{f:translate(key: 'breadcrumb', extensionName: 'my_sitepackage')}">
<nav aria-label="{f:translate(key: 'footerNavigation', extensionName: 'my_sitepackage')}">Form Landmarks
Search forms are landmarks -- use role="search" or the <search> element:
<search>
<form action="/search" method="get">
<label for="search-input" class="visually-hidden">
<f:translate key="searchLabel" extensionName="my_sitepackage" />
</label>
<input type="search" id="search-input" name="q"
placeholder="{f:translate(key: 'searchPlaceholder', extensionName: 'my_sitepackage')}">
<button type="submit">
<f:translate key="searchSubmit" extensionName="my_sitepackage" />
</button>
</form>
</search>Label Landmarks
When multiple landmarks of the same type exist, label them to differentiate:
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>Without labels, screen reader users cannot distinguish between multiple <nav> elements.
Structure Main Content
Use landmarks, headings, and lists to provide structure within <main>. Screen reader users rely on these to navigate complex pages. Group related content with <section> and label each with a heading or aria-label.
---
Headings and Content Order
Heading Hierarchy
- Exactly one
<h1>per page - Never skip heading levels (
<h1>then<h3>without<h2>) - Headings create the document outline -- screen reader users navigate by headings
- Every content section should start with a heading
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h3>Subsection</h3>
<h2>Another Section</h2>Content Order
DOM order must match visual order. Content must make sense without CSS.
Never use these to reorder content semantically:
orderin Flexbox/Grid (visual reorder for responsive layout is OK if DOM stays logical)flex-direction: row-reverseorcolumn-reverseto reverse reading ordertabindexvalues > 0 to override tab order- CSS
floattricks that put content before its heading in the DOM
---
Links
Link vs. Button Decision
| Use | Element | Behavior |
|---|---|---|
| Navigate to URL/anchor | <a href="..."> | Changes page/location |
| Trigger action on current page | <button> | Toggle, submit, open dialog |
| Download file | <a href="..." download> | Initiates download |
Never:
- Use
<div onclick>or<span onclick>as links or buttons - Use
<a>withouthref(removes it from tab order) - Use
<a href="javascript:void(0)">-- use<button>instead - Use
<button>for navigation -- use<a>instead
Link Styling
Links in body text must be underlined. Color alone is not sufficient -- 8% of men have color vision deficiencies and cannot distinguish link color from text color.
// Basic/_links.scss
// Links in running text must be underlined
.ce-textmedia,
.news-detail__content,
.accordion-body {
a:not([class]) {
text-decoration: underline;
text-underline-offset: 0.1875rem;
&:hover {
text-decoration-thickness: 0.125rem;
}
}
}
// Navigation links are exempted (context makes purpose obvious)
.nav-link,
.btn {
text-decoration: none;
}Download Links
Download links must communicate: file type, file size, and that it's a download.
<a href="/files/report.pdf" download>
Annual Report 2024 (PDF, 2.4 MB)
</a>Email Links
Always show the email address as visible text:
<!-- Good -->
<a href="mailto:info@example.com">info@example.com</a>
<!-- Bad: hides the address -->
<a href="mailto:info@example.com">Contact us</a>Linked Images
The image alt text becomes the link's accessible name:
<!-- Logo link to homepage -->
<a href="/">
<img src="/logo.svg" alt="Company Name - Back to homepage">
</a>
<!-- Image + text link: empty alt to avoid redundancy -->
<a href="/products/widget">
<img src="/widget.jpg" alt="">
Widget Pro 3000
</a>Links Opening in New Window
When using target="_blank", inform users:
<a href="https://external.com" target="_blank"
rel="noopener noreferrer">
External Resource
<span class="visually-hidden">(opens in new tab)</span>
</a>Or use a visual icon with screen reader text. Never open links in new tabs without indication.
Client-Side Rendering
Not applicable for standard TYPO3 sitepackages (server-side rendering). If using JS-heavy frontend components that manipulate history: ensure focus management on route changes and update <title> dynamically.
Clickable Card Patterns
See references/patterns-clickable-cards.md for the 5 patterns with trade-offs. Recommended: pseudo-element stretch pattern.
---
Buttons
Button Labeling
Every button must have an accessible name. Three patterns:
<!-- 1. Text content (best) -->
<button type="button">Save changes</button>
<!-- 2. Icon button with visually hidden text (preferred) -->
<button type="button">
<svg aria-hidden="true">...</svg>
<span class="visually-hidden">Close dialog</span>
</button>
<!-- 3. Icon button with aria-label (acceptable) -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>Never use title as the only accessible name for buttons.
Resetting Button Styles
When buttons need custom styling, reset properly but keep focus styles:
.btn-reset {
appearance: none;
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
&:focus-visible {
outline: 0.1875rem solid $primary;
outline-offset: 0.125rem;
}
}CSS all: unset also works but removes ALL styles including focus -- always re-add focus styles.
Button States and Properties
<!-- Toggle button (show/hide) -->
<button type="button" aria-expanded="false" aria-controls="panel-1">
Show details
</button>
<!-- Pressed toggle (bold/italic toolbar) -->
<button type="button" aria-pressed="false">Bold</button>
<!-- Button with popup -->
<button type="button" aria-haspopup="true" aria-expanded="false">
Options
</button>Update aria-expanded and aria-pressed via JavaScript when toggling.
Don't Disable Buttons
Never use `disabled` on submit buttons. Disabled buttons:
- Are not focusable -- keyboard users cannot find them
- Have no hover state -- users get no feedback why they can't submit
- Have low contrast by default -- hard to read
- Provide no explanation of WHY they are disabled
Instead: keep the button enabled, validate on click, and show error messages:
<!-- Bad -->
<button type="submit" disabled>Submit</button>
<!-- Good: always enabled, show errors on click -->
<button type="submit">Submit</button>---
Color and Contrast
Minimum Contrast Ratios (WCAG AA)
| Element | Ratio |
|---|---|
| Normal text (<24px / <19px bold) | 4.5:1 |
| Large text (>=24px / >=19px bold) | 3:1 |
| UI components (borders, icons, focus indicators) | 3:1 |
Rules:
- Never convey information through color alone -- always add icons, patterns, underlines, or text
- Test all Bootstrap theme color combinations against their backgrounds
- Test with Chrome DevTools color contrast tools and emulated color deficiencies
- The current WCAG contrast formula has known limitations -- use judgment alongside the numbers
// Verify these combinations in your theme:
$primary: #0069b4; // Must have 4.5:1 against $white for text
$secondary: #d1530f; // Must have 4.5:1 against $white for text
$danger: #dc3545; // Must have 4.5:1 against $white for text---
CSS Units and User Preferences
Relative Units Only
Key points:
remfor font sizes, spacing, padding, margins, media queriesemfor component-relative sizing (e.g., icon size relative to text)- Never
pxexcept for 1px borders and box-shadows - Users who set larger browser font sizes must get proportionally larger layouts
- At 200% zoom, no content loss or horizontal scrolling may occur
Media Queries for User Settings
Respond to these user preferences via CSS media queries:
// Dark mode
@media (prefers-color-scheme: dark) {
// Swap colors, adjust image brightness/contrast
}
// Increased contrast
@media (prefers-contrast: more) {
// Increase contrast ratios, thicken borders, remove subtle backgrounds
}
// Forced colors / Windows High Contrast Mode
@media (forced-colors: active) {
// Use system colors, don't override backgrounds
// Borders become the primary visual structure
// Custom backgrounds and box-shadows disappear
}
// Reduced transparency
@media (prefers-reduced-transparency: reduce) {
// Replace semi-transparent overlays with solid colors
}
// Reduced motion
@media (prefers-reduced-motion: reduce) { ... }forced-colors is critical for Windows High Contrast Mode users. When active, the browser overrides all colors -- custom backgrounds and shadows disappear. Ensure layouts work with borders as the primary visual structure.
display: contents Danger
display: contents removes the element's box from layout but also removes its semantics from the accessibility tree in some browsers.
Never use on:
<button>-- loses button role<a>-- loses link role<table>,<tr>,<td>-- loses table structure- Any interactive element
Safe to use on:
- Wrapper
<div>or<span>that exist only for layout purposes
list-style: none Removes List Semantics
Safari + VoiceOver removes list semantics when list-style: none is applied. Fix by adding role="list" explicitly:
// When using list-style: none, ALWAYS add role="list" in the template
.nav-list,
.skiplinks__list,
.breadcrumb,
.pagination {
list-style: none;
}<!-- Required in Fluid when list-style: none is used -->
<ul class="nav-list" role="list">
<li>...</li>
</ul>Reduced Motion
// Basic/_accessibility.scss
@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;
}
}When adding animations, provide a subtle fallback (e.g., opacity fade) for reduced-motion users instead of removing all animation. For animation patterns with prefers-reduced-motion support, see the typo3-frontend-patterns skill.
---
Focus Management
Focus Styles
All interactive elements must have visible focus indicators:
// Basic/_accessibility.scss
*:focus-visible {
outline: 0.1875rem solid $primary;
outline-offset: 0.125rem;
}
*:focus:not(:focus-visible) {
outline: none;
}:focus-visible shows focus only for keyboard navigation, not mouse clicks. The outline must have 3:1 contrast against adjacent colors.
Making Elements Focusable
| Attribute | Behavior | Use case |
|---|---|---|
| No tabindex | Native focusable elements (<a href>, <button>, <input>) | Default |
tabindex="0" | Adds to natural tab order | Custom interactive elements |
tabindex="-1" | Focusable via JS .focus() only | Programmatic focus targets |
tabindex="1+" | Never use | Overrides natural order, creates chaos |
Moving Focus
When opening modals, drawers, or overlays: 1. Save the previously focused element 2. Move focus to the new content (first focusable element or the container itself) 3. When closing, return focus to the saved element
function openDialog(dialog: HTMLElement, trigger: HTMLElement): void {
const previousFocus = document.activeElement as HTMLElement;
dialog.removeAttribute('hidden');
dialog.querySelector<HTMLElement>('[autofocus], button, a, input')?.focus();
dialog.addEventListener('close', () => {
previousFocus?.focus();
}, { once: true });
}Focus Containment with inert
The modern alternative to manual focus trapping is the inert attribute:
function openModal(modal: HTMLElement): void {
document.querySelectorAll('body > *:not(.modal-overlay)').forEach((el) => {
el.setAttribute('inert', '');
});
modal.removeAttribute('inert');
modal.querySelector<HTMLElement>('[autofocus], button')?.focus();
}
function closeModal(modal: HTMLElement, trigger: HTMLElement): void {
document.querySelectorAll('[inert]').forEach((el) => {
el.removeAttribute('inert');
});
trigger.focus();
}inert makes elements non-focusable AND invisible to screen readers. For legacy browser support, keep the manual trapFocus() function as fallback:
function trapFocus(element: HTMLElement): void {
const focusable = element.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
element.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first?.focus();
}Preserve Order
DOM order must match visual order. CSS properties that break this:
orderin Flex/Gridflex-direction: row-reverse/column-reverseposition: absolutemoving elements visually out of sequence
Skip Links
See references/patterns-skiplinks.md. Mandatory for every page.
Keyboard Navigation Summary
| Key | Action |
|---|---|
Tab / Shift+Tab | Sequential focus navigation |
Enter / Space | Activate button or link |
Escape | Close dropdown, modal, popover |
| Arrow keys | Navigate within tabs, accordions (optional enhancement) |
---
ARIA Reference
Core ARIA Patterns
ARIA creates relationships between elements using ID references:
<!-- aria-labelledby: element labeled BY another element -->
<div role="region" aria-labelledby="section-title">
<h2 id="section-title">Latest News</h2>
</div>
<!-- aria-describedby: element described BY another element -->
<input type="email" aria-describedby="email-help">
<p id="email-help">We'll never share your email.</p>
<!-- aria-controls: element controls another element -->
<button aria-expanded="false" aria-controls="panel-1">Toggle</button>
<div id="panel-1" hidden>Panel content</div>ARIA Rules
1. Don't use ARIA if native HTML works -- <button> over <div role="button"> 2. Don't change native semantics unnecessarily -- don't add role="button" to <a> 3. All interactive ARIA elements must be keyboard accessible 4. Don't use `role="presentation"` or `aria-hidden="true"` on focusable elements 5. All interactive elements must have an accessible name
Images
<!-- Informative image -->
<img src="..." alt="Description of what the image shows">
<!-- Decorative image (empty alt is sufficient, role="presentation" is optional) -->
<img src="..." alt="">
<!-- Complex image (chart, infographic) -->
<figure>
<img src="..." alt="Brief description" aria-describedby="desc-{uid}">
<figcaption id="desc-{uid}">Detailed description...</figcaption>
</figure>Live Regions
For dynamic content updates that screen readers should announce:
<!-- Polite: announced at next pause (filter results, status updates) -->
<div aria-live="polite" aria-atomic="true">
3 results found
</div>
<!-- Assertive: interrupts immediately (errors, urgent alerts) -->
<div role="alert">
Session expires in 2 minutes
</div>
<!-- Status: polite announcement for form/process status -->
<div role="status">
Form saved successfully
</div>---
Automated Accessibility Testing
axe-core Integration
Add automated accessibility testing to Playwright:
// tests/e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/contact', '/news'];
for (const page of pages) {
test(`has no critical a11y violations on ${page}`, async ({ page: browserPage }) => {
await browserPage.goto(page);
const results = await new AxeBuilder({ page: browserPage })
.withTags(['wcag2a', 'wcag2aa', 'best-practice'])
.analyze();
expect(results.violations).toEqual([]);
});
}Install: npm install --save-dev @axe-core/playwright in the root project.
Browser DevTools Workflows
Accessibility Tree: Chrome DevTools > Elements > Accessibility pane shows computed accessible name, role, and properties. Enable "Full accessibility tree" in DevTools settings for the tree view.
Debug Roles and Names: Inspect element > Accessibility pane > "Computed Properties" shows resolved role, name, description. Common issue: missing accessible name on icon buttons or empty links.
Visualize Tab Order: Chrome DevTools > Elements > Accessibility > check "Show tab order". Numbers overlay shows actual tab sequence -- verify it matches visual reading order.
Emulate Vision Deficiencies: Chrome DevTools > Rendering > "Emulate vision deficiencies". Options: Protanopia, Deuteranopia, Tritanopia, Achromatopsia, Blurred vision. Also emulate: prefers-reduced-motion, prefers-color-scheme, prefers-contrast, forced-colors.
Custom Debugging Selectors:
/* Find images without alt */
img:not([alt]) { outline: 0.25rem solid red !important; }
/* Find links without accessible text */
a:empty:not([aria-label]):not([aria-labelledby]) { outline: 0.25rem solid red !important; }
/* Find buttons without accessible text */
button:empty:not([aria-label]):not([aria-labelledby]) { outline: 0.25rem solid red !important; }
/* Find missing lang attribute */
html:not([lang]) { outline: 0.25rem solid red !important; }Linter Rules
Add accessibility Stylelint rules to Build/.stylelintrc.json:
{
"plugins": ["stylelint-a11y"],
"rules": {
"a11y/media-prefers-reduced-motion": true,
"a11y/no-outline-none": true,
"a11y/no-text-size-adjust": true
}
}CI Integration
Add axe-core to the Playwright CI pipeline. Configure the accessibility test job in your CI pipeline.
---
Responsive Accessibility
Touch Targets
Minimum 44x44px for all interactive elements on mobile (WCAG 2.5.8):
@media (pointer: coarse) {
.btn,
.nav-link,
.dropdown-item,
.form-check-label {
min-height: 2.75rem;
min-width: 2.75rem;
}
}---
Content Element Checklist
For every new content element, verify:
- [ ] Page has unique, descriptive
<title> - [ ]
langattribute set on<html>(and inline foreign text where needed) - [ ] Viewport allows zoom (no
user-scalable=noormaximum-scale=1) - [ ] Heading hierarchy is correct (no skipped levels, one
<h1>) - [ ] DOM order matches visual order
- [ ] All images have meaningful
alttext (oralt=""if decorative) - [ ] Links in body text are underlined
- [ ] Download links show file type and size
- [ ] Links opening in new tab inform users
- [ ] Buttons have accessible names (no empty icon buttons)
- [ ] Buttons are never
disabled-- validate on click instead - [ ] Interactive elements use correct element (
<a>vs<button>) - [ ] Interactive elements are keyboard-accessible
- [ ] ARIA attributes are correct and complete
- [ ]
aria-expandedtoggles on disclosure triggers - [ ] Color contrast meets WCAG AA (4.5:1 text, 3:1 UI)
- [ ] Information is not conveyed by color alone
- [ ] Focus order follows visual/DOM order
- [ ] Focus indicator is visible (3:1 contrast)
- [ ]
prefers-reduced-motiondisables animations - [ ]
forced-colorsdoes not break layout - [ ] Touch targets are at least 44x44px
- [ ]
list-style: nonelists haverole="list" - [ ] Form fields have associated
<label> - [ ] Error messages are announced to screen readers (
aria-liveorrole="alert") - [ ] Live regions announce dynamic content updates
- [ ] Labels exist in both DE and EN
- [ ] axe-core Playwright test passes
---
Recommended Reading
Web Accessibility Cookbook by Manuel Matuzovic (O'Reilly, 2024) -- comprehensive guide to web accessibility with practical recipes.
Pattern: Accessible Data Filtering & Tables
Patterns for accessible filtering, pagination, sorting, and semantic tables.
Filter Form
Wrap filters in a <form> with role="search". Group related filters with <fieldset> + <legend>. Always provide a submit button -- never rely solely on auto-submit via onChange.
<!-- Partials/Filter/FilterForm.html -->
<form class="filter-form" role="search"
aria-label="{f:translate(key: 'filter.label', extensionName: 'my_sitepackage')}"
method="get" action="{filterAction}">
<fieldset class="filter-form__group">
<legend class="filter-form__legend">
<f:translate key="filter.category" extensionName="my_sitepackage" />
</legend>
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label" for="filter-category">
<f:translate key="filter.category" extensionName="my_sitepackage" />
</label>
<select id="filter-category" name="category" class="form-select">
<option value=""><f:translate key="filter.all" extensionName="my_sitepackage" /></option>
<f:for each="{categories}" as="cat">
<option value="{cat.uid}" selected="{f:if(condition: '{cat.uid} == {activeCategory}', then: 'selected')}">{cat.title}</option>
</f:for>
</select>
</div>
<div class="col-12 col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<f:translate key="filter.submit" extensionName="my_sitepackage" />
</button>
</div>
</div>
</fieldset>
</form>Live Region for Results
When filtering updates results via AJAX, announce the count using aria-live="polite". Never use aria-live="assertive" for filter results -- too intrusive.
<!-- Partials/Filter/ResultStatus.html -->
<div class="filter-status visually-hidden" aria-live="polite" aria-atomic="true" id="filter-status">
<f:translate key="filter.resultsFound" extensionName="my_sitepackage" arguments="{0: resultCount}" />
</div>TypeScript
// TypeScript/Plugins/filterStatus.ts
const STATUS_ID = 'filter-status';
export function announceFilterResults(count: number, labelTemplate: string): void {
let status = document.getElementById(STATUS_ID);
if (!status) {
status = document.createElement('div');
status.id = STATUS_ID;
status.className = 'filter-status visually-hidden';
status.setAttribute('aria-live', 'polite');
status.setAttribute('aria-atomic', 'true');
document.body.appendChild(status);
}
// Clear and re-set to trigger screen reader announcement
status.textContent = '';
requestAnimationFrame(() => {
status!.textContent = labelTemplate.replace('{0}', String(count));
});
}Pagination
Wrap in <nav aria-label="Pagination">. Active page: aria-current="page". Disabled prev/next: aria-disabled="true" (not disabled on anchors).
<!-- Partials/Filter/Pagination.html -->
<f:if condition="{pagination.lastPage} > 1">
<nav class="mt-4" aria-label="{f:translate(key: 'pagination.label', extensionName: 'my_sitepackage')}">
<ul class="pagination justify-content-center">
<li class="page-item{f:if(condition: '{pagination.currentPage} == 1', then: ' disabled')}">
<f:if condition="{pagination.currentPage} > 1">
<f:then>
<a class="page-link" href="{f:uri.action(arguments: '{page: pagination.previousPage}')}">
<span aria-hidden="true">«</span>
<span class="visually-hidden"><f:translate key="pagination.previous" extensionName="my_sitepackage" /></span>
</a>
</f:then>
<f:else>
<span class="page-link" aria-disabled="true">
<span aria-hidden="true">«</span>
</span>
</f:else>
</f:if>
</li>
<f:for each="{pagination.pages}" as="page">
<li class="page-item{f:if(condition: '{page.number} == {pagination.currentPage}', then: ' active')}">
<a class="page-link" href="{f:uri.action(arguments: '{page: page.number}')}"
{f:if(condition: '{page.number} == {pagination.currentPage}', then: 'aria-current="page"')}>{page.number}</a>
</li>
</f:for>
<li class="page-item{f:if(condition: '{pagination.currentPage} == {pagination.lastPage}', then: ' disabled')}">
<f:if condition="{pagination.currentPage} < {pagination.lastPage}">
<f:then>
<a class="page-link" href="{f:uri.action(arguments: '{page: pagination.nextPage}')}">
<span aria-hidden="true">»</span>
<span class="visually-hidden"><f:translate key="pagination.next" extensionName="my_sitepackage" /></span>
</a>
</f:then>
<f:else>
<span class="page-link" aria-disabled="true"><span aria-hidden="true">»</span></span>
</f:else>
</f:if>
</li>
</ul>
</nav>
</f:if>Sort Controls
Use aria-sort on <th> to announce sort direction. Wrap sort trigger in a <button> inside <th> -- never make <th> itself clickable.
TypeScript
// TypeScript/Plugins/sortableTable.ts
type SortDirection = 'ascending' | 'descending';
export function initSortableTable(): void {
document.querySelectorAll<HTMLTableElement>('.table-sortable').forEach((table) => {
const headers = table.querySelectorAll<HTMLTableCellElement>('thead th[data-sortable]');
headers.forEach((th) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'table-sort-btn';
button.textContent = th.textContent?.trim() || '';
th.textContent = '';
th.appendChild(button);
button.addEventListener('click', () => {
const current = th.getAttribute('aria-sort') as SortDirection | null;
const next: SortDirection = current === 'ascending' ? 'descending' : 'ascending';
headers.forEach((other) => other.removeAttribute('aria-sort'));
th.setAttribute('aria-sort', next);
sortColumn(table, Array.from(th.parentElement!.children).indexOf(th), next);
});
});
});
}
function sortColumn(table: HTMLTableElement, col: number, dir: SortDirection): void {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
const mod = dir === 'ascending' ? 1 : -1;
rows.sort((a, b) => {
const aT = a.cells[col]?.textContent?.trim() || '';
const bT = b.cells[col]?.textContent?.trim() || '';
return aT.localeCompare(bT, undefined, { numeric: true }) * mod;
});
rows.forEach((row) => tbody.appendChild(row));
}SCSS
// Components/_table-sortable.scss
.table-sort-btn {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: $font-weight-bold;
white-space: nowrap;
&::after {
content: '\2195';
font-size: 0.75rem;
opacity: 0.4;
}
&:focus-visible {
outline: 0.125rem solid $primary;
outline-offset: 0.125rem;
border-radius: 0.125rem;
}
}
th[aria-sort='ascending'] .table-sort-btn::after { content: '\2191'; opacity: 1; }
th[aria-sort='descending'] .table-sort-btn::after { content: '\2193'; opacity: 1; }Semantic Table Structure
Always use <table>, <thead>, <tbody>, <th>, <td> -- never div-based tables. Add <caption> and scope attributes on header cells. See patterns-responsive-tables.md for mobile handling.
<!-- Partials/Table/DataTable.html -->
<div class="table-responsive-wrap">
<table class="table table-striped table-sortable">
<caption class="visually-hidden">
<f:translate key="table.caption" extensionName="my_sitepackage" />
</caption>
<thead>
<tr>
<f:for each="{columns}" as="col">
<th scope="col" data-sortable="{f:if(condition: col.sortable, then: 'true')}">{col.label}</th>
</f:for>
</tr>
</thead>
<tbody>
<f:for each="{rows}" as="row">
<tr>
<f:for each="{row.cells}" as="cell" iteration="iter">
<f:if condition="{iter.isFirst}">
<f:then><th scope="row">{cell}</th></f:then>
<f:else><td>{cell}</td></f:else>
</f:if>
</f:for>
</tr>
</f:for>
</tbody>
</table>
</div>For complex tables with multi-level headers, use the headers attribute to link cells to their headers.
Labels
<!-- locallang.xlf (EN) -->
<trans-unit id="filter.label"><source>Filter</source></trans-unit>
<trans-unit id="filter.category"><source>Category</source></trans-unit>
<trans-unit id="filter.all"><source>All</source></trans-unit>
<trans-unit id="filter.search"><source>Search</source></trans-unit>
<trans-unit id="filter.searchPlaceholder"><source>Search...</source></trans-unit>
<trans-unit id="filter.submit"><source>Apply filter</source></trans-unit>
<trans-unit id="filter.resultsFound"><source>{0} results found</source></trans-unit>
<trans-unit id="pagination.label"><source>Pagination</source></trans-unit>
<trans-unit id="pagination.previous"><source>Previous page</source></trans-unit>
<trans-unit id="pagination.next"><source>Next page</source></trans-unit>
<trans-unit id="table.caption"><source>Data overview</source></trans-unit>
<!-- de.locallang.xlf (DE) -->
<trans-unit id="filter.label"><source>Filter</source><target>Filter</target></trans-unit>
<trans-unit id="filter.category"><source>Category</source><target>Kategorie</target></trans-unit>
<trans-unit id="filter.all"><source>All</source><target>Alle</target></trans-unit>
<trans-unit id="filter.search"><source>Search</source><target>Suche</target></trans-unit>
<trans-unit id="filter.searchPlaceholder"><source>Search...</source><target>Suchen...</target></trans-unit>
<trans-unit id="filter.submit"><source>Apply filter</source><target>Filter anwenden</target></trans-unit>
<trans-unit id="filter.resultsFound"><source>{0} results found</source><target>{0} Ergebnisse gefunden</target></trans-unit>
<trans-unit id="pagination.label"><source>Pagination</source><target>Seitennavigation</target></trans-unit>
<trans-unit id="pagination.previous"><source>Previous page</source><target>Vorherige Seite</target></trans-unit>
<trans-unit id="pagination.next"><source>Next page</source><target>Nächste Seite</target></trans-unit>
<trans-unit id="table.caption"><source>Data overview</source><target>Datenübersicht</target></trans-unit>Checklist
| Rule | Detail |
|---|---|
Filter in <form> | Use role="search" or labeled landmark |
| Submit button | Always provide -- no JS-only filtering |
<fieldset> + <legend> | Group related filter controls |
| Live region | aria-live="polite" for result count updates |
Never assertive | Filter results are not urgent announcements |
Pagination in <nav> | aria-label="Pagination" |
| Active page | aria-current="page" on current page link |
| Disabled links | aria-disabled="true", not disabled attribute |
Semantic <table> | Never div-based tables |
<caption> | Table title, can be visually-hidden |
scope on <th> | scope="col" or scope="row" |
| Sort direction | aria-sort="ascending" / "descending" on <th> |
| Sort buttons | <button> inside <th>, not clickable <th> |
| Mobile tables | See patterns-responsive-tables.md |
Pattern: Accessible Forms
Patterns for building accessible, usable forms. All examples use Bootstrap 5 classes and TYPO3 Fluid conventions.
Form Landmarks
Important forms can be promoted to landmarks for screen reader quick-navigation. Search forms must use the search role:
<!-- Search form as landmark -->
<search>
<form aria-label="{f:translate(key: 'searchForm', extensionName: 'my_sitepackage')}">
<label for="search-input" class="visually-hidden">
<f:translate key="searchLabel" extensionName="my_sitepackage" />
</label>
<div class="input-group">
<input type="search" id="search-input" name="q" class="form-control"
placeholder="{f:translate(key: 'searchPlaceholder', extensionName: 'my_sitepackage')}"
autocomplete="off" />
<button type="submit" class="btn btn-primary">
<f:translate key="searchSubmit" extensionName="my_sitepackage" />
</button>
</div>
</form>
</search>The <search> element is supported in all modern browsers (2023+). For legacy browsers, add role="search" on the <form> as fallback.
Form Basics
Rules:
1. Use native form elements -- <input>, <select>, <textarea>, not <div> with ARIA roles 2. Use the right element -- radio buttons for single choice, checkboxes for multiple, <select> for long lists 3. Keep forms short -- only ask for what is strictly needed 4. Label every field -- no unlabeled inputs, ever 5. Use `autocomplete` -- helps users fill forms faster and enables password managers
Complete Example Form
<form method="post" novalidate>
<div class="mb-3">
<label for="fullName" class="form-label required">Full name</label>
<input type="text" id="fullName" name="fullName" class="form-control"
autocomplete="name" required
aria-required="true" />
</div>
<div class="mb-3">
<label for="email" class="form-label required">Email address</label>
<input type="email" id="email" name="email" class="form-control"
autocomplete="email" required
aria-required="true"
aria-describedby="email-help" />
<div id="email-help" class="form-text">We will never share your email.</div>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Phone</label>
<input type="tel" id="phone" name="phone" class="form-control"
autocomplete="tel" />
</div>
<div class="mb-3">
<label for="message" class="form-label required">Message</label>
<textarea id="message" name="message" class="form-control" rows="5"
required aria-required="true"></textarea>
</div>
<button type="submit" class="btn btn-primary">Send message</button>
</form>Common autocomplete Values
| Value | Field |
|---|---|
name | Full name |
given-name | First name |
family-name | Last name |
email | Email address |
tel | Phone number |
street-address | Street address |
postal-code | Zip / postal code |
address-level2 | City |
country-name | Country |
organization | Company name |
Labeling Form Elements
Five techniques, ranked by preference:
1. <label for="id"> -- always the best choice
<label for="username" class="form-label">Username</label>
<input type="text" id="username" class="form-control" />2. aria-labelledby -- when the label text exists elsewhere
<h2 id="shipping-heading">Shipping address</h2>
<!-- ... -->
<input type="text" class="form-control" aria-labelledby="shipping-heading" />3. aria-label -- when no visible label exists
<!-- Search field with only a placeholder and button -->
<input type="search" class="form-control" aria-label="Search" placeholder="Search..." />4. title attribute -- not recommended
Triggers a tooltip on hover, not reliably exposed to all assistive technologies.
5. Placeholder as label -- NEVER do this
Anti-patterns to avoid:
<!-- BAD: placeholder disappears when user types -->
<input type="text" class="form-control" placeholder="Your name" />
<!-- BAD: label hidden when a visible label would be better -->
<label for="name" class="visually-hidden">Name</label>
<input type="text" id="name" class="form-control" placeholder="Name" />Always prefer a visible <label>. Only use visually-hidden labels when the design truly cannot accommodate visible text (e.g., a compact search bar).
Describing Form Fields
Use aria-describedby to connect help text to a field. Help text must be always visible, not hidden in tooltips:
<div class="mb-3">
<label for="password" class="form-label required">Password</label>
<input type="password" id="password" class="form-control"
autocomplete="new-password" required
aria-required="true"
aria-describedby="password-help" />
<div id="password-help" class="form-text">
At least 8 characters, one uppercase letter, and one number.
</div>
</div>Multiple descriptions can be combined:
<input type="text" id="iban" class="form-control"
aria-describedby="iban-help iban-format" />
<div id="iban-help" class="form-text">Your bank account number.</div>
<div id="iban-format" class="form-text">Format: DE89 3704 0044 0532 0130 00</div>Error Handling
Field-Level Errors
<div class="mb-3">
<label for="email" class="form-label required">Email address</label>
<input type="email" id="email" class="form-control is-invalid"
aria-required="true"
aria-invalid="true"
aria-describedby="email-error" />
<div id="email-error" class="invalid-feedback" role="alert">
Please enter a valid email address.
</div>
</div>Key rules:
- `aria-invalid="true"` on the erroneous field
- `aria-describedby` pointing to the error message
id - `role="alert"` or `aria-live="assertive"` for dynamically injected errors
- Bootstrap:
.is-invalidon the input,.invalid-feedbackfor the message
Error Summary at Top of Form
<div class="alert alert-danger" role="alert" aria-labelledby="error-summary-heading">
<h2 id="error-summary-heading" class="alert-heading h5">
2 errors found
</h2>
<ul class="mb-0">
<li><a href="#email">Email address: Please enter a valid email address.</a></li>
<li><a href="#phone">Phone: Please enter a valid phone number.</a></li>
</ul>
</div>Error Count in Page Title
When server-side validation fails, prepend the error count to the page title so screen readers announce it immediately:
<title>2 errors - Contact - Site Name</title>SCSS for Error States
// Components/_form-errors.scss
.form-control.is-invalid,
.form-select.is-invalid,
.form-check-input.is-invalid {
border-color: $danger;
&:focus {
border-color: $danger;
box-shadow: 0 0 0 0.2rem rgba($danger, 0.25);
}
}
.invalid-feedback {
display: block;
font-size: 0.875rem;
color: $danger;
margin-top: 0.25rem;
}
// Error summary
.alert-danger {
ul {
padding-left: 1.25rem;
}
a {
color: $danger;
font-weight: $font-weight-bold;
}
}Grouping Fields
Use <fieldset> and <legend> for semantically related fields. Screen readers announce the legend before each field in the group.
Radio Group
<fieldset class="mb-3">
<legend class="form-label">Preferred contact method</legend>
<div class="form-check">
<input type="radio" id="contact-email" name="contact" value="email"
class="form-check-input" />
<label for="contact-email" class="form-check-label">Email</label>
</div>
<div class="form-check">
<input type="radio" id="contact-phone" name="contact" value="phone"
class="form-check-input" />
<label for="contact-phone" class="form-check-label">Phone</label>
</div>
</fieldset>Address Group
<fieldset class="mb-4">
<legend class="h5">Billing address</legend>
<div class="mb-3">
<label for="street" class="form-label">Street</label>
<input type="text" id="street" class="form-control" autocomplete="street-address" />
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="zip" class="form-label">Zip</label>
<input type="text" id="zip" class="form-control" autocomplete="postal-code" />
</div>
<div class="col-md-8 mb-3">
<label for="city" class="form-label">City</label>
<input type="text" id="city" class="form-control" autocomplete="address-level2" />
</div>
</div>
</fieldset>Multi-Step Forms
Step Indicator (Fluid Partial)
<!-- PageView/Partials/Form/StepIndicator.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<nav aria-label="{f:translate(key: 'formProgress', extensionName: 'my_sitepackage')}">
<ol class="step-indicator">
<f:for each="{steps}" as="step" iteration="iter">
<li class="step-indicator__item{f:if(condition: '{iter.cycle} == {currentStep}', then: ' active')}{f:if(condition: '{iter.cycle} < {currentStep}', then: ' completed')}">
<span class="step-indicator__number"
{f:if(condition: '{iter.cycle} == {currentStep}', then: 'aria-current="step"')}>{iter.cycle}</span>
<span class="step-indicator__label">{step.label}</span>
</li>
</f:for>
</ol>
</nav>Step Indicator SCSS
// Components/_step-indicator.scss
.step-indicator {
display: flex;
list-style: none;
padding: 0;
margin: 0 0 2rem;
&__item {
flex: 1;
text-align: center;
position: relative;
+ .step-indicator__item::before {
content: '';
position: absolute;
top: 1rem;
left: -50%;
right: 50%;
height: 0.125rem;
background-color: $border-color;
}
&.completed + .step-indicator__item::before,
&.completed::before {
background-color: $success;
}
}
&__number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: $gray-300;
color: $white;
font-weight: $font-weight-bold;
font-size: 0.875rem;
position: relative;
z-index: 1;
}
&__item.active &__number {
background-color: $primary;
}
&__item.completed &__number {
background-color: $success;
}
&__label {
display: block;
font-size: 0.75rem;
margin-top: 0.5rem;
color: $text-muted;
}
&__item.active &__label {
color: $body-color;
font-weight: $font-weight-bold;
}
}Each step should also have a heading (e.g., <h2>Step 2: Shipping address</h2>) so screen readers understand which section they are in.
TYPO3 Form Framework Integration
The TYPO3 form framework generates its own HTML structure. To apply these accessibility patterns, override the Fluid templates and add ARIA attributes.
Key points:
- Add
aria-required="true"to required fields in overridden partials - Add
aria-invalidandaria-describedbyfor validation error states - Use
role="alert"on error message containers - Add
autocompleteattributes via YAML form configuration or Fluid overrides
Labels
<!-- locallang.xlf (EN) -->
<trans-unit id="searchForm"><source>Search form</source></trans-unit>
<trans-unit id="searchLabel"><source>Search</source></trans-unit>
<trans-unit id="searchPlaceholder"><source>Search...</source></trans-unit>
<trans-unit id="searchSubmit"><source>Search</source></trans-unit>
<trans-unit id="formProgress"><source>Form progress</source></trans-unit>
<trans-unit id="formErrorSummary"><source>Errors found</source></trans-unit>
<!-- de.locallang.xlf (DE) -->
<trans-unit id="searchForm">
<source>Search form</source>
<target>Suchformular</target>
</trans-unit>
<trans-unit id="searchLabel">
<source>Search</source>
<target>Suche</target>
</trans-unit>
<trans-unit id="searchPlaceholder">
<source>Search...</source>
<target>Suchen...</target>
</trans-unit>
<trans-unit id="searchSubmit">
<source>Search</source>
<target>Suchen</target>
</trans-unit>
<trans-unit id="formProgress">
<source>Form progress</source>
<target>Formularfortschritt</target>
</trans-unit>
<trans-unit id="formErrorSummary">
<source>Errors found</source>
<target>Fehler gefunden</target>
</trans-unit>Checklist
- [ ] Every input has a visible
<label>with matchingfor/id - [ ] Required fields have
aria-required="true"and a visual indicator (*) - [ ]
autocompleteattribute set on common fields (name, email, tel, address) - [ ] Help text connected via
aria-describedbyand always visible - [ ] Error messages use
aria-describedbyandrole="alert" - [ ] Erroneous fields have
aria-invalid="true"and.is-invalid - [ ] Error summary at top of form with links to fields
- [ ] Error count in page
<title>on server-side validation failure - [ ] Related fields grouped with
<fieldset>and<legend> - [ ] Radio/checkbox groups wrapped in
<fieldset>with descriptive<legend> - [ ] Search forms use
role="search"on the<form>element - [ ] Multi-step forms show progress with
aria-current="step" - [ ] Each form step has a heading
- [ ] Native form elements used -- no
<div>role hacks - [ ] No placeholder-only labels
- [ ] TYPO3 form framework templates overridden to include ARIA attributes
Pattern: Accessible Navigation
Accessible main navigation using <nav> with semantic lists, b13/menus TreeMenu, and proper ARIA attributes. Navigation is a list of links, never an ARIA menu.
TypoScript DataProcessor
page.10.dataProcessing {
10 = B13\Menus\DataProcessing\TreeMenu
10 {
as = mainNavigation
levels = 3
expandAll = 1
includeSpacer = 0
}
}Fluid Template
<!-- PageView/Partials/Header/MainNavigation.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<nav id="main-navigation" class="main-nav"
aria-label="{f:translate(key: 'mainNavigation', extensionName: 'my_sitepackage')}">
<ul class="main-nav__list" role="list">
<f:for each="{mainNavigation}" as="item">
<li class="main-nav__item{f:if(condition: '{item.hasSubpages}', then: ' has-children')}">
<a href="{item.link}" class="main-nav__link"
{f:if(condition: '{item.current}', then: 'aria-current="page"')}>
{item.title}
</a>
<f:if condition="{item.hasSubpages}">
<button class="main-nav__toggle" type="button"
aria-expanded="false" aria-controls="subnav-{item.uid}"
aria-label="{f:translate(key: 'openSubmenu', extensionName: 'my_sitepackage')}: {item.title}">
<span class="main-nav__toggle-icon" aria-hidden="true"></span>
</button>
<ul class="main-nav__sublist" id="subnav-{item.uid}" role="list">
<f:for each="{item.subpages}" as="subitem">
<li class="main-nav__subitem">
<a href="{subitem.link}" class="main-nav__sublink"
{f:if(condition: '{subitem.current}', then: 'aria-current="page"')}>
{subitem.title}
</a>
</li>
</f:for>
</ul>
</f:if>
</li>
</f:for>
</ul>
</nav>
</html>Key decisions:
aria-current="page"on active link -- screen readers announce "current page" (WCAG 2.4.8)role="list"becauselist-style: noneremoves semantics in Safari/VoiceOver (WCAG 1.3.1)- Link + Button pattern for submenus: parent stays a link, separate button toggles children (WCAG 4.1.2)
Mobile Toggle
<!-- PageView/Partials/Header/MobileNavToggle.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<button class="main-nav-toggle" type="button"
aria-expanded="false" aria-controls="main-navigation"
aria-label="{f:translate(key: 'toggleNavigation', extensionName: 'my_sitepackage')}">
<span class="main-nav-toggle__bar" aria-hidden="true"></span>
<span class="main-nav-toggle__bar" aria-hidden="true"></span>
<span class="main-nav-toggle__bar" aria-hidden="true"></span>
</button>
</html>TypeScript
// TypeScript/Plugins/navigation.ts
const TOGGLE_SELECTOR = '.main-nav-toggle';
const NAV_SELECTOR = '#main-navigation';
const SUBMENU_TOGGLE_SELECTOR = '.main-nav__toggle';
const OPEN_CLASS = 'is-open';
export function initNavigation(): void {
const toggle = document.querySelector<HTMLButtonElement>(TOGGLE_SELECTOR);
const nav = document.querySelector<HTMLElement>(NAV_SELECTOR);
if (!toggle || !nav) return;
toggle.addEventListener('click', () => {
const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!isExpanded));
nav.classList.toggle(OPEN_CLASS);
if (!isExpanded) {
nav.querySelector<HTMLAnchorElement>('a')?.focus();
}
});
nav.addEventListener('click', (event: Event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(SUBMENU_TOGGLE_SELECTOR);
if (!button) return;
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!isExpanded));
const submenuId = button.getAttribute('aria-controls');
if (submenuId) {
document.getElementById(submenuId)?.classList.toggle(OPEN_CLASS);
}
});
nav.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
const openToggle = nav.querySelector<HTMLButtonElement>(`${SUBMENU_TOGGLE_SELECTOR}[aria-expanded="true"]`);
if (openToggle) {
openToggle.setAttribute('aria-expanded', 'false');
const submenuId = openToggle.getAttribute('aria-controls');
if (submenuId) {
document.getElementById(submenuId)?.classList.remove(OPEN_CLASS);
}
openToggle.focus();
}
});
}SCSS
// Navigation/_main-nav.scss
// Link styles + active page highlighting
.main-nav__link,
.main-nav__sublink {
text-decoration: none;
color: $body-color;
padding: 0.5rem 1rem;
display: block;
transition: color 0.2s ease, background-color 0.2s ease;
&:hover {
color: $primary;
background-color: $gray-100;
}
// Style via aria-current, not a .active class -- couples visual and semantic state
&[aria-current="page"] {
color: $primary;
font-weight: $font-weight-bold;
border-bottom: 0.1875rem solid $primary;
}
}
.main-nav__list,
.main-nav__sublist {
list-style: none;
margin: 0;
padding: 0;
}
// Mobile: hidden by default, fullscreen overlay when open
.main-nav {
@media (max-width: map-get($grid-breakpoints, lg) - 0.02px) {
display: none;
&.is-open {
display: block;
position: fixed;
inset: 0;
z-index: 1050;
background-color: $white;
overflow-y: auto;
animation: navFadeIn 0.3s ease;
}
}
}
// Burger button
.main-nav-toggle {
display: none;
flex-direction: column;
justify-content: center;
gap: 0.3125rem;
width: 2.75rem;
height: 2.75rem;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
@media (max-width: map-get($grid-breakpoints, lg) - 0.02px) {
display: flex;
}
&__bar {
display: block;
width: 100%;
height: 0.125rem;
background-color: $body-color;
border-radius: 0.0625rem;
transition: transform 0.3s ease, opacity 0.3s ease;
}
&[aria-expanded="true"] {
.main-nav-toggle__bar:nth-child(1) { transform: translateY(0.4375rem) rotate(45deg); }
.main-nav-toggle__bar:nth-child(2) { opacity: 0; }
.main-nav-toggle__bar:nth-child(3) { transform: translateY(-0.4375rem) rotate(-45deg); }
}
}
// Submenus
.main-nav__sublist {
display: none;
&.is-open { display: block; }
}
.main-nav__toggle {
background: transparent;
border: none;
padding: 0.5rem;
cursor: pointer;
&-icon {
display: block;
width: 0.625rem;
height: 0.625rem;
border-right: 0.125rem solid $body-color;
border-bottom: 0.125rem solid $body-color;
transform: rotate(45deg);
transition: transform 0.2s ease;
}
&[aria-expanded="true"] .main-nav__toggle-icon {
transform: rotate(-135deg);
}
}
// Slide-in animation
@keyframes navFadeIn {
from { opacity: 0; transform: translateX(-100%); }
to { opacity: 1; transform: translateX(0); }
}
// Reduced motion: no animation at all
@media (prefers-reduced-motion: reduce) {
.main-nav.is-open { animation: none; }
.main-nav-toggle__bar { transition: none; }
.main-nav__toggle-icon { transition: none; }
}Labels
<!-- locallang.xlf (EN) -->
<trans-unit id="mainNavigation"><source>Main navigation</source></trans-unit>
<trans-unit id="toggleNavigation"><source>Toggle navigation</source></trans-unit>
<trans-unit id="openSubmenu"><source>Open submenu</source></trans-unit>
<trans-unit id="closeSubmenu"><source>Close submenu</source></trans-unit>
<!-- de.locallang.xlf (DE) -->
<trans-unit id="mainNavigation">
<source>Main navigation</source>
<target>Hauptnavigation</target>
</trans-unit>
<trans-unit id="toggleNavigation">
<source>Toggle navigation</source>
<target>Navigation ein-/ausblenden</target>
</trans-unit>
<trans-unit id="openSubmenu">
<source>Open submenu</source>
<target>Untermenü öffnen</target>
</trans-unit>
<trans-unit id="closeSubmenu">
<source>Close submenu</source>
<target>Untermenü schließen</target>
</trans-unit>Anti-Pattern: role="menu"
CRITICAL: Never use `role="menu"` for website navigation.
role="menu" is for application menus (desktop-style File/Edit/View). It changes keyboard expectations: arrow-key navigation between menuitem elements, Tab leaves the menu. Website navigation is a list of links:
<!-- CORRECT: nav > ul > li > a -->
<nav aria-label="Main navigation">
<ul role="list">
<li><a href="/about">About</a></li>
</ul>
</nav>
<!-- WRONG: Do NOT use menubar/menu/menuitem -->
<nav role="menubar">
<ul role="menu">
<li role="none"><a role="menuitem" href="/about">About</a></li>
</ul>
</nav>Checklist
| Requirement | Implementation |
|---|---|
<nav> with aria-label | Identifies the navigation landmark |
<ul> with role="list" | Restores list semantics (Safari/VoiceOver fix) |
aria-current="page" on active link | Screen readers announce "current page" |
Style via [aria-current="page"] | Visual and semantic state coupled |
Burger toggle: aria-expanded + aria-controls | Announces state, associates with nav |
Hidden nav uses display: none | Removed from tab order, not just visually hidden |
Submenu toggle: aria-expanded + aria-controls | Announces submenu state |
| Escape closes open submenu | Standard keyboard interaction |
prefers-reduced-motion respected | No slide animation for motion-sensitive users |
No role="menu/menuitem" | Navigation is a list of links, not an app menu |
| Bilingual labels (EN + DE) | All aria-labels via locallang.xlf |
Skip link target #main-navigation | See patterns-skiplinks.md |
Pattern: Clickable Cards
Cards and teasers often need to be entirely clickable while remaining accessible. Naive approaches create problems for screen readers, keyboard users, or break native browser behavior.
Requirements for Accessible Clickable Cards
- Entire card must be clickable for mouse/touch users
- Screen readers should announce only ONE link per card (not multiple redundant links)
- Link text must be meaningful (not empty or generic "read more")
- Right-click / middle-click / URL preview must work
- Text within the card must remain selectable
- Focus indicator must be visible
Solution 1: Wrapping Everything in <a> (NOT Recommended)
<a href="/detail" class="ce-teaser">
<img src="image.jpg" alt="" class="ce-teaser__image">
<h3 class="ce-teaser__title">Card Title</h3>
<p class="ce-teaser__text">Description text that explains the card content.</p>
</a>Problems: Screen readers read ALL content as one long link text -- extremely verbose and confusing. Cannot nest interactive elements (buttons, other links) inside an <a> element.
Solution 2: Separate Links (NOT Recommended)
<div class="ce-teaser">
<a href="/detail"><img src="image.jpg" alt="Card Title" class="ce-teaser__image"></a>
<h3 class="ce-teaser__title"><a href="/detail">Card Title</a></h3>
<p class="ce-teaser__text">Description text.</p>
<a href="/detail" class="btn btn-primary">Read more</a>
</div>Problems: Creates 3 tab stops all pointing to the same URL. Keyboard users must tab through redundant links. Screen readers announce the same destination multiple times.
Solution 3: Empty Link Overlay (Acceptable)
<div class="ce-teaser">
<a href="/detail" class="ce-teaser__overlay" aria-label="Card Title"></a>
<img src="image.jpg" alt="" class="ce-teaser__image">
<h3 class="ce-teaser__title">Card Title</h3>
<p class="ce-teaser__text">Description text.</p>
</div>.ce-teaser {
position: relative;
&__overlay {
position: absolute;
inset: 0;
z-index: 1;
}
}Problems: Text selection does not work anywhere on the card. The aria-label must be kept in sync with the heading manually. Empty links are not ideal semantically.
Solution 4: Pseudo-Element Stretch (RECOMMENDED)
The heading link gets a ::after pseudo-element stretched over the entire card. This is the recommended default for all card/teaser components.
Why this pattern wins:
- Only one link and one tab stop per card
- Meaningful link text comes from the heading itself
- Right-click and middle-click work on the heading link
- Text outside the heading is still selectable (z-index layering)
- No extra markup or JavaScript needed
Fluid Component Template
<!-- ContentElements/Partials/Molecule/Teaser.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<article class="ce-teaser">
<f:if condition="{image}">
<div class="ce-teaser__media">
<f:image image="{image}" alt="" class="ce-teaser__image" loading="lazy" />
</div>
</f:if>
<div class="ce-teaser__body">
<h3 class="ce-teaser__title">
<f:link.typolink parameter="{link}" class="ce-teaser__link">
{title}
</f:link.typolink>
</h3>
<f:if condition="{text}">
<p class="ce-teaser__text">{text}</p>
</f:if>
<f:if condition="{tags}">
<ul class="ce-teaser__tags" role="list">
<f:for each="{tags}" as="tag">
<li class="ce-teaser__tag">{tag}</li>
</f:for>
</ul>
</f:if>
</div>
</article>
</html>SCSS
// ContentElements/_ce-teaser.scss
.ce-teaser {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
background-color: $white;
border: 1px solid $border-color;
border-radius: $border-radius;
overflow: hidden;
transition: box-shadow 0.2s ease;
// Focus-visible indicator on the card when the link is focused
&:has(.ce-teaser__link:focus-visible) {
outline: 0.1875rem solid $primary;
outline-offset: 0.125rem;
}
&:hover {
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.1);
}
&__media {
aspect-ratio: 16 / 9;
overflow: hidden;
}
&__image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
.ce-teaser:hover & {
transform: scale(1.03);
}
}
&__body {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 1.25rem;
}
&__title {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
// The pseudo-element stretches the link over the entire card
&__link {
color: inherit;
text-decoration: none;
&::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
}
&:hover {
text-decoration: underline;
}
// Hide default outline -- card itself shows focus indicator via :has()
&:focus-visible {
outline: none;
}
}
&__text {
font-size: 0.875rem;
color: $text-muted;
margin-bottom: 0.75rem;
// Raise above the pseudo-element so text is selectable
position: relative;
z-index: 2;
}
&__tags {
list-style: none;
padding: 0;
margin: 0;
margin-top: auto;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
// Raise above the pseudo-element so tags are selectable
position: relative;
z-index: 2;
}
&__tag {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background-color: $gray-100;
border-radius: $border-radius-sm;
color: $text-muted;
}
}Z-index layering explained: The ::after pseudo-element sits at z-index: 1, making the entire card clickable. Elements that should remain selectable (text, tags) get position: relative; z-index: 2, raising them above the overlay. The heading link itself stays at the default stacking level, so clicks on non-raised areas pass through to ::after.
Solution 5: JavaScript Click Delegation (Acceptable)
// Assets/JavaScript/Components/ClickableCard.ts
export function initClickableCards(): void {
const cards = document.querySelectorAll<HTMLElement>('[data-clickable-card]');
cards.forEach((card) => {
const primaryLink = card.querySelector<HTMLAnchorElement>('a[data-card-link]');
if (!primaryLink) return;
card.style.cursor = 'pointer';
card.addEventListener('click', (event: MouseEvent) => {
// Do not hijack clicks on interactive elements
const target = event.target as HTMLElement;
if (target.closest('a, button, input, select, textarea')) return;
if (event.ctrlKey || event.metaKey) {
window.open(primaryLink.href, '_blank');
} else {
primaryLink.click();
}
});
});
}Problems: No URL preview on hover over the card body. No native right-click context menu with link options. Requires JavaScript -- card is not clickable without it.
Labels
<!-- locallang.xlf (EN) -->
<trans-unit id="teaser.readMore"><source>Read more about %s</source></trans-unit>
<!-- de.locallang.xlf (DE) -->
<trans-unit id="teaser.readMore">
<source>Read more about %s</source>
<target>Mehr erfahren über %s</target>
</trans-unit>Playwright E2E Test
// tests/e2e/clickable-card.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Clickable Card (Pseudo-Element Pattern)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/teaser-page');
});
test('card has exactly one link', async ({ page }) => {
const links = page.locator('.ce-teaser').first().locator('a');
await expect(links).toHaveCount(1);
});
test('link has meaningful text', async ({ page }) => {
const linkText = await page.locator('.ce-teaser__link').first().textContent();
expect(linkText?.trim()).not.toBe('');
});
test('card is focusable via keyboard', async ({ page }) => {
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveClass(/ce-teaser__link/);
});
test('card text is selectable', async ({ page }) => {
const zIndex = await page.locator('.ce-teaser__text').first().evaluate(
(el) => window.getComputedStyle(el).zIndex,
);
expect(Number(zIndex)).toBeGreaterThan(1);
});
});Key Rules
1. Use Solution 4 (pseudo-element) as default for all card/teaser components 2. One link per card -- screen readers should encounter exactly one link 3. Meaningful link text -- heading text serves as the link label 4. Text selectability -- raise non-link content above the overlay with z-index: 2 5. Focus indicator on the card -- use :has(:focus-visible) to show outline on the card boundary 6. No px units -- use rem throughout, except for 1px borders 7. ce- prefix -- content element cards use ce-teaser, ce-card, etc.
Pattern: Disclosure Widgets & Accordions
Accessible show/hide patterns for disclosure widgets and accordions.
Content Hiding Decision Matrix
| Technique | Visually Hidden | Hidden from AT | Keeps Space | Use Case |
|---|---|---|---|---|
display: none | Yes | Yes | No | Fully hidden content |
visibility: hidden | Yes | Yes | Yes | Hidden with transitions (animatable) |
clip-path / sr-only | Yes | No | No | Screen reader only text |
aria-hidden="true" | No | Yes | -- | Decorative visuals irrelevant for AT |
hidden attribute | Yes | Yes | No | Semantic display: none equivalent |
inert attribute | No | Partially | -- | Non-interactive (behind modals) |
Safari/VoiceOver caveat: list-style: none removes list semantics. Fix with role="list".
Native Disclosure Widget
Use <details>/<summary> for simple toggles without animation needs:
<details class="disclosure">
<summary class="disclosure__toggle">More information</summary>
<div class="disclosure__content">
<p>Hidden content revealed on toggle.</p>
</div>
</details>Screen reader behavior varies: some announce "expanded/collapsed", others "open/closed". The <summary> always receives an implicit button role. No JavaScript required.
Custom Disclosure Widget
Use when you need animations or full control over behavior:
<div class="disclosure">
<button class="disclosure__toggle"
aria-expanded="false"
aria-controls="disclosure-panel-1">
Show details
</button>
<div class="disclosure__content"
id="disclosure-panel-1"
role="region"
aria-labelledby="disclosure-heading-1"
hidden>
<p>Toggled content.</p>
</div>
</div>export function initDisclosure(container: HTMLElement): void {
const toggle = container.querySelector<HTMLButtonElement>('.disclosure__toggle');
const content = container.querySelector<HTMLElement>('.disclosure__content');
if (!toggle || !content) return;
toggle.addEventListener('click', () => {
const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!isExpanded));
content.hidden = isExpanded;
});
}Accordion -- Fluid ContentElement Template
<!-- ContentElement/Accordion.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<f:layout name="ContentElement" />
<f:section name="Main">
<div class="accordion" data-accordion>
<f:for each="{data.items}" as="item" iteration="iter">
<div class="accordion__item">
<h3 class="accordion__header">
<button class="accordion__toggle"
id="accordion-heading-{data.uid}-{iter.index}"
aria-expanded="false"
aria-controls="accordion-panel-{data.uid}-{iter.index}"
type="button">
{item.header}
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel"
id="accordion-panel-{data.uid}-{iter.index}"
role="region"
aria-labelledby="accordion-heading-{data.uid}-{iter.index}"
hidden>
<div class="accordion__body">
<f:format.html>{item.bodytext}</f:format.html>
</div>
</div>
</div>
</f:for>
</div>
</f:section>
</html>SCSS
// Components/_accordion.scss
.accordion {
border: 0.0625rem solid $border-color;
border-radius: $border-radius;
overflow: hidden;
&__item {
border-bottom: 0.0625rem solid $border-color;
&:last-child {
border-bottom: none;
}
}
&__header {
margin: 0;
}
&__toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.25rem;
border: none;
background: none;
font-size: 1rem;
font-weight: $font-weight-semibold;
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $gray-100;
}
&:focus-visible {
outline: 0.1875rem solid $primary;
outline-offset: -0.1875rem;
}
&[aria-expanded="true"] .accordion__icon {
transform: rotate(180deg);
}
}
&__icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
margin-left: 1rem;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='currentColor' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
transition: transform 0.3s ease;
}
&__panel {
&:not([hidden]) {
animation: accordion-expand 0.3s ease;
}
}
&__body {
padding: 0 1.25rem 1rem;
}
}
@keyframes accordion-expand {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.accordion__icon,
.accordion__panel {
transition: none;
animation: none;
}
}TypeScript
// TypeScript/Plugins/accordion.ts
export function initAccordion(): void {
document.querySelectorAll<HTMLElement>('[data-accordion]').forEach((accordion) => {
const toggles = accordion.querySelectorAll<HTMLButtonElement>('.accordion__toggle');
toggles.forEach((toggle) => {
toggle.addEventListener('click', () => {
const panelId = toggle.getAttribute('aria-controls');
const panel = panelId ? document.getElementById(panelId) : null;
if (!panel) return;
const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!isExpanded));
panel.hidden = isExpanded;
});
});
// Arrow key navigation between accordion headers
accordion.addEventListener('keydown', (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (!target.classList.contains('accordion__toggle')) return;
const items = [...toggles];
const index = items.indexOf(target as HTMLButtonElement);
let next: HTMLButtonElement | undefined;
if (event.key === 'ArrowDown') {
next = items[(index + 1) % items.length];
} else if (event.key === 'ArrowUp') {
next = items[(index - 1 + items.length) % items.length];
} else if (event.key === 'Home') {
next = items[0];
} else if (event.key === 'End') {
next = items[items.length - 1];
}
if (next) {
event.preventDefault();
next.focus();
}
});
});
}Labels
<!-- locallang.xlf (EN) -->
<trans-unit id="accordion.expand"><source>Expand section</source></trans-unit>
<trans-unit id="accordion.collapse"><source>Collapse section</source></trans-unit>
<!-- de.locallang.xlf (DE) -->
<trans-unit id="accordion.expand">
<source>Expand section</source>
<target>Bereich aufklappen</target>
</trans-unit>
<trans-unit id="accordion.collapse">
<source>Collapse section</source>
<target>Bereich zuklappen</target>
</trans-unit>Playwright E2E Test
// tests/e2e/accordion.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Accordion', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/accordion-test');
});
test('panels are hidden by default', async ({ page }) => {
const panels = page.locator('.accordion__panel');
for (const panel of await panels.all()) {
await expect(panel).toHaveAttribute('hidden', '');
}
});
test('expands panel on toggle click', async ({ page }) => {
await page.locator('.accordion__toggle').first().click();
await expect(page.locator('.accordion__toggle').first()).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.accordion__panel').first()).not.toHaveAttribute('hidden');
});
test('collapses panel on second click', async ({ page }) => {
const toggle = page.locator('.accordion__toggle').first();
await toggle.click();
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('.accordion__panel').first()).toHaveAttribute('hidden', '');
});
test('supports arrow key navigation between headers', async ({ page }) => {
await page.locator('.accordion__toggle').first().focus();
await page.keyboard.press('ArrowDown');
await expect(page.locator('.accordion__toggle').nth(1)).toBeFocused();
});
test('each panel has role="region" and aria-labelledby', async ({ page }) => {
const panels = page.locator('.accordion__panel');
for (const panel of await panels.all()) {
await expect(panel).toHaveAttribute('role', 'region');
await expect(panel).toHaveAttribute('aria-labelledby');
}
});
});Key Rules
1. No forced single-open -- let users open multiple panels simultaneously 2. Button inside heading -- toggle must be a <button> inside an <h2>/<h3> 3. `aria-expanded` on button -- not on the panel 4. `aria-controls` + `id` -- button points to its panel 5. `role="region"` + `aria-labelledby` -- panel points back to its heading 6. Arrow keys -- Up/Down/Home/End navigate between accordion headers 7. `prefers-reduced-motion` -- disable animations for users who request it 8. `hidden` attribute -- use it instead of CSS-only hiding for correct AT behavior
Pattern: Responsive Tables
Pattern for tables that scroll horizontally on mobile or reflow into a card layout.
Approach 1: Horizontal Scroll (Default)
Best for data tables where column relationships matter.
SCSS
// Basic/_tables.scss
.table-responsive-wrap {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
// Visual scroll indicator on mobile
@media (max-width: map-get($grid-breakpoints, md) - 0.02px) {
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, rgba($white, 0.8));
pointer-events: none;
}
// Hide indicator when scrolled to end
&.is-scrolled-end::after {
display: none;
}
}
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
th,
td {
padding: 0.75rem;
border-bottom: 1px solid $border-color;
text-align: left;
vertical-align: top;
}
th {
font-weight: $font-weight-bold;
background-color: $gray-100;
white-space: nowrap;
}
tbody tr:hover {
background-color: rgba($primary, 0.04);
}
}TypeScript
// TypeScript/Plugins/responsiveTables.ts
export function initResponsiveTables(): void {
// Wrap all tables in RTE content with responsive wrapper
const tables = document.querySelectorAll<HTMLTableElement>(
'.ce-textmedia table, .news-detail__content table, .accordion-body table',
);
tables.forEach((table) => {
if (table.parentElement?.classList.contains('table-responsive-wrap')) return;
const wrapper = document.createElement('div');
wrapper.classList.add('table-responsive-wrap');
table.parentNode?.insertBefore(wrapper, table);
wrapper.appendChild(table);
// Track scroll position for fade indicator
wrapper.addEventListener('scroll', () => {
const isEnd = wrapper.scrollLeft + wrapper.clientWidth >= wrapper.scrollWidth - 2;
wrapper.classList.toggle('is-scrolled-end', isEnd);
}, { passive: true });
});
}Approach 2: Card Reflow (for simple tables)
Best for 2-3 column tables where each row is a self-contained record.
SCSS
// Components/_table-reflow.scss
@media (max-width: map-get($grid-breakpoints, md) - 0.02px) {
.table-reflow {
thead {
// Visually hide but keep for accessibility
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
tr {
display: block;
margin-bottom: 1rem;
border: 1px solid $border-color;
border-radius: $border-radius;
padding: 0.75rem;
}
td {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0.375rem 0;
border: none;
border-bottom: 1px solid $gray-200;
&:last-child {
border-bottom: none;
}
// Show column header as label
&::before {
content: attr(data-label);
font-weight: $font-weight-bold;
margin-right: 1rem;
flex-shrink: 0;
max-width: 40%;
}
}
}
}TypeScript
// TypeScript/Plugins/tableReflow.ts
export function initTableReflow(): void {
const tables = document.querySelectorAll<HTMLTableElement>('.table-reflow');
tables.forEach((table) => {
const headers = Array.from(table.querySelectorAll('thead th')).map(
(th) => th.textContent?.trim() || '',
);
table.querySelectorAll('tbody td').forEach((td, index) => {
const headerIndex = index % headers.length;
if (headers[headerIndex]) {
td.setAttribute('data-label', headers[headerIndex]);
}
});
});
}Fluid Usage
Tables from the RTE are automatically wrapped by initResponsiveTables(). For content element tables, add the class manually:
<!-- Simple scroll -->
<div class="table-responsive-wrap">
<table>...</table>
</div>
<!-- Card reflow -->
<table class="table-reflow">...</table>Entrypoint
Add to main.entry.ts:
import { initResponsiveTables } from '../TypeScript/Plugins/responsiveTables';
document.addEventListener('DOMContentLoaded', () => {
initResponsiveTables();
});Pattern: Skip Link Navigation
Skip links are mandatory in every sitepackage. They allow keyboard users and screen reader users to jump directly to main content sections, bypassing repetitive navigation.
Fluid Partial
<!-- PageView/Partials/Header/Skiplinks.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<nav class="skiplinks" aria-label="{f:translate(key: 'skiplinks', extensionName: 'my_sitepackage')}">
<ul class="skiplinks__list">
<li>
<a href="#main-content" class="skiplinks__link">
<f:translate key="skipToContent" extensionName="my_sitepackage" />
</a>
</li>
<li>
<a href="#main-navigation" class="skiplinks__link">
<f:translate key="skipToNavigation" extensionName="my_sitepackage" />
</a>
</li>
<li>
<a href="#main-footer" class="skiplinks__link">
<f:translate key="skipToFooter" extensionName="my_sitepackage" />
</a>
</li>
</ul>
</nav>
</html>Page Layout Integration
Skip links must be the first focusable element on the page:
<!-- PageView/Layouts/Default.html -->
<f:layout />
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:vite="http://typo3.org/ns/Praetorius/ViteAssetCollector/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<f:render section="Assets" />
<!-- Skip links: MUST be first focusable element -->
<f:render partial="Header/Skiplinks" />
<header class="main-header" id="main-header">
<nav id="main-navigation" aria-label="{f:translate(key: 'mainNavigation', extensionName: 'my_sitepackage')}">
<f:render section="Header" />
</nav>
</header>
<main id="main-content">
<f:render section="Main" />
</main>
<footer class="main-footer" id="main-footer">
<f:render section="Footer" />
</footer>
<f:render partial="BackToTop" />
</html>Required Target IDs
Every page layout must have these landmark IDs:
| ID | Element | Purpose |
|---|---|---|
#main-content | <main> | Primary content area |
#main-navigation | <nav> | Primary navigation |
#main-footer | <footer> | Page footer |
#main-header | <header> | Page header (optional skip target) |
SCSS
// Basic/_accessibility.scss
.skiplinks {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 9999;
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0.5rem;
justify-content: center;
}
&__link {
// Hidden by default, visible on focus
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
// Visible on focus (keyboard Tab)
&:focus {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: $primary;
color: $white;
font-weight: $font-weight-bold;
font-size: 0.875rem;
text-decoration: none;
border-radius: 0 0 $border-radius $border-radius;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
outline: 0.1875rem solid $white;
outline-offset: -0.1875rem;
z-index: 9999;
}
&:hover {
background-color: darken($primary, 10%);
}
}
}
// Scroll margin for skip link targets
#main-content,
#main-navigation,
#main-footer {
scroll-margin-top: calc(var(--header-height, 5rem) + 1rem);
}Labels
<!-- locallang.xlf (EN) -->
<trans-unit id="skiplinks"><source>Skip links</source></trans-unit>
<trans-unit id="skipToContent"><source>Skip to content</source></trans-unit>
<trans-unit id="skipToNavigation"><source>Skip to navigation</source></trans-unit>
<trans-unit id="skipToFooter"><source>Skip to footer</source></trans-unit>
<trans-unit id="mainNavigation"><source>Main navigation</source></trans-unit>
<!-- de.locallang.xlf (DE) -->
<trans-unit id="skiplinks">
<source>Skip links</source>
<target>Sprungnavigation</target>
</trans-unit>
<trans-unit id="skipToContent">
<source>Skip to content</source>
<target>Zum Inhalt springen</target>
</trans-unit>
<trans-unit id="skipToNavigation">
<source>Skip to navigation</source>
<target>Zur Navigation springen</target>
</trans-unit>
<trans-unit id="skipToFooter">
<source>Skip to footer</source>
<target>Zum Seitenende springen</target>
</trans-unit>
<trans-unit id="mainNavigation">
<source>Main navigation</source>
<target>Hauptnavigation</target>
</trans-unit>Playwright E2E Test
// tests/e2e/skiplinks.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Skip Links', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('has skip link as first focusable element', async ({ page }) => {
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveClass(/skiplinks__link/);
});
test('skip to content link points to #main-content', async ({ page }) => {
await expect(page.locator('.skiplinks__link').first()).toHaveAttribute('href', '#main-content');
});
test('target elements have correct IDs', async ({ page }) => {
await expect(page.locator('#main-content')).toBeAttached();
await expect(page.locator('#main-navigation')).toBeAttached();
await expect(page.locator('#main-footer')).toBeAttached();
});
test('skip link is visible on focus', async ({ page }) => {
await page.locator('.skiplinks__link').first().focus();
await expect(page.locator('.skiplinks__link').first()).toBeVisible();
});
});Key Rules
1. Always the first focusable element -- before logo, navigation, search 2. At least "Skip to content" -- navigation and footer links are recommended 3. Hidden until focused -- uses clip/overflow technique, not display: none 4. High z-index -- must appear above everything when visible 5. High contrast -- primary color background with white text 6. Bilingual labels -- DE + EN in locallang.xlf 7. Target IDs are mandatory -- #main-content, #main-navigation, #main-footer