Implementation Guide: HTML Landmarks & Accessibility Structures
This guide walks through the concrete code changes needed after an HTML accessibility analysis. Each section covers one part of the page, explains what needs to change and why, and provides ready-to-use code snippets.
1. Basic Structure — <header>, <main>, <footer>
The page must use semantic landmark elements so that screen readers and Google can identify the major regions of the page. Each landmark should also carry an aria-label for extra clarity.
<header n:attr="aria-label => $web['ARIA_HEADER_LABEL'] ?? null">
{include "./Homepage/partials/components/header.latte"}
</header>
{* -- Main Content -- *}
<main id="main-content" tabindex="-1" n:attr="aria-label => $web['ARIA_MAIN_LABEL'] ?? null">
{include content}
</main>
<footer class="footer" n:attr="aria-label => $web['ARIA_FOOTER_LABEL'] ?? null">
...
</footer>
If the page includes a sidebar, wrap it in <aside>:
<aside class="service__rightside --w-12 --w-l-3">
...
</aside>
Clickable contact details need descriptive aria-label attributes so screen reader users hear a meaningful announcement instead of just a raw number or address.
Phone:
<a href="tel:{$web['PHONE']}"
aria-label="{$web['ARIA_PHONE_LABEL'] ?? 'Zavolat na telefonní číslo'}: {$web['PER_PHONE']} {$web['PHONE']}"
onclick="sendContactFacebook()">
{$web['PHONE']}
</a>
Email:
<a href="mailto:{$web['MAIL']}"
aria-label="{$web['ARIA_EMAIL_LABEL'] ?? 'Odeslat e-mail na adresu'}: {$web['MAIL']}">
{$web['MAIL']}
</a>
2. Skip Link
A skip link allows keyboard users to jump over repeated navigation directly to the main content. It must be the first focusable element after <body>. It is visually hidden by default and becomes visible only when focused via Tab.
{* Skip link — first element after <body> *}
<a href="#main-content"
class="skip-link"
style="position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden;"
onfocus="this.style.left='10px'; this.style.top='10px'; this.style.width='auto'; this.style.height='auto'; this.style.zIndex='9999'; this.style.background='#000'; this.style.color='#fff'; this.style.padding='10px'; this.style.textDecoration='none';"
onblur="this.style.left='-9999px';">
Přeskočit na hlavní obsah
</a>
The target id="main-content" must exist on the <main> element (see Section 1).
3. Main Navigation
The navigation must be wrapped in <nav> with an aria-label to distinguish it from other nav regions on the page (e.g. footer nav, breadcrumb).
<nav class="header__menu js--header-menu"
aria-label="{$web['ARIA_NAV_MAIN_LABEL'] ?? 'Hlavní navigace'}">
{include 'menu.latte'}
</nav>
The burger menu toggle must be a <button> (not a <div>) so it is keyboard-accessible and announced correctly by screen readers.
<button type="button"
class="header__button js--header-button"
aria-expanded="false"
aria-controls="header-menu"
aria-label="{$web['ARIA_MENU_TOGGLE_LABEL']}"
>
<span class="header__button-icon" aria-hidden="true"></span>
<span class="header__button-label">Menu</span>
</button>
aria-expanded must be toggled via JavaScript between "true" and "false" when the menu opens and closes. aria-controls references the id of the menu element being controlled.
if(headerButton && header && headerMenu){
headerButton.addEventListener("click", function (){
const isExpanded = headerButton.getAttribute("aria-expanded") === "true";
headerMenu.classList.toggle("header__menu--active");
headerButton.classList.toggle("header__button--active");
header.classList.toggle("header--active");
headerButton.setAttribute("aria-expanded", !isExpanded ? "true" : "false");
headerButton.setAttribute("aria-label",
!isExpanded
? (headerButton.dataset.ariaLabelClose || "Zavřít hlavní menu")
: (headerButton.dataset.ariaLabelOpen || "Otevřít hlavní menu")
);
});
}
aria-current="page" is set dynamically using n:attr — it only renders if $isCurrent is true, so it never appears as aria-current="false" in the DOM, which would be incorrect.<nav class="menu" id="header-menu" n:attr="aria-label => $web['ARIA_NAV_MAIN_LABEL']">
<div class="menu__content --flex --flex-centre">
<ul class="menu__list">
{foreach $nav as $item}
{var $dropdown = !empty($item['chilldren']) && $item['id'] === 2}
{var $isCurrent = isset($page) && isset($page['id']) && ($page['id'] === $item['id'] || (isset($page['parent_id']) && $page['parent_id'] === $item['id']))}
<li n:class="'menu__item', $dropdown ? 'menu__item--dropdown'">
<a class="menu__link" href="{$item|link|noescape}" n:attr="aria-current => $isCurrent ? 'page' : null">
4. Breadcrumb Navigation
The breadcrumb must be wrapped in <nav aria-label="Breadcrumb navigace"> so it is distinct from the main navigation. Each link should have a meaningful aria-label (especially the home icon link, which has no visible text). The active/current page item should have aria-current="page".
<nav class="breadcrumb-wrapper" aria-label="{$web['ARIA_BREADCRUMB_LABEL']}">
{$page|breadcrumb:$homepage|noescape}
</nav>
$li = Html::el('li', [
'class' => 'breadcrumb__item breadcrumb__item--active'
])
->setText($original['name'])
->setAttribute('aria-current', 'page');
$el->addHtml($li);
5. Blog and Articles
Each article must be wrapped in <article> and contain its own <header>. This allows screen readers to treat each article as an independent, self-contained piece of content. The publication date must use a <time> element with a machine-readable datetime attribute.
<article class="section__column --w-12 --w-m-8">
<header class="article__header" aria-labelledby="article-title">
<div class="article__spec spec --flex --flex-centre-y">
<a href="{$item['rubric']|link}" n:ifset='$item["rubric"]' class="spec__item spec__item--category">
{$item["rubric"]|name}
</a>
<time class="spec__item spec__item--date" datetime="{$item['date_inserted']->format('Y-m-d')}">
{$item['date_inserted']->format('j. n. Y')}
</time>
</div>
<div class="article__perex">
<a href="{$item|link}" class="article__link">
<h3 id="article-title" class="article__subtitle">{$item|header}</h3>
</a>
<div class="article__text">{$item|perex:false:130|noescape}</div>
</div>
</header>
...
</article>
6. Forms
Every input field must have a corresponding <label> tied to it via the for / id relationship. This is the most basic form accessibility requirement — without it, screen readers cannot announce what a field is for.
<label class="message-form__label" n:attr="for => $form[5]->getHtmlId()">
Your label text
</label>
The following items still need to be reviewed and implemented on a per-form basis:
aria-required="true"on all mandatory fieldsrole="alert"on error messages so they are announced immediately when they appear- Visible text description for required field indicators (asterisks) — e.g. a visually hidden
<span>saying "povinné pole" aria-describedbyon checkboxes that have additional helper text
7. Modal Windows
A modal must have role="dialog", aria-modal="true", and aria-hidden="true" by default (set to "false" when opened). The title of the modal should be referenced via aria-labelledby.
<div class="cookie-modal modal"
id="cookie_modal"
role="dialog"
aria-modal="true"
aria-hidden="true"
aria-labelledby="cookie-modal-title">
...
</div>
Inputs inside the modal should use both visible labels and aria-label:
Focus Trap
When a modal is open, keyboard focus must be trapped inside it. The function below handles this, including Esc key support to close the modal:
function trapFocus(modal) {
if (!modal) return;
const focusableSelectors = `
a[href],
area[href],
input:not([disabled]):not([type="hidden"]),
select:not([disabled]),
textarea:not([disabled]),
button:not([disabled]),
iframe,
object,
embed,
[contenteditable],
[tabindex]:not([tabindex="-1"])
`;
const focusableEls = Array.from(modal.querySelectorAll(focusableSelectors));
if (focusableEls.length === 0) return;
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
firstEl.focus();
function handleKeyDown(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
if (e.key === 'Escape') {
closeCookie();
}
}
modal.addEventListener('keydown', handleKeyDown);
return () => modal.removeEventListener('keydown', handleKeyDown);
}
function setInert(modal) {
const bodyChildren = Array.from(document.body.children);
bodyChildren.forEach(el => {
if (el !== modal && !el.contains(modal)) {
el.setAttribute('inert', '');
el.setAttribute('aria-hidden', 'true');
}
});
}
function removeInert() {
const bodyChildren = Array.from(document.body.children);
bodyChildren.forEach(el => {
el.removeAttribute('inert');
el.removeAttribute('aria-hidden');
});
}
Opening the modal:
cookieRemodal.openModal();
const modal = cookieRemodal.element;
// Hide everything outside the modal from screen readers
document.querySelectorAll('body > :not(#' + cookieRemodal.elementId + ')').forEach(el => {
el.setAttribute('inert', '');
});
modal.setAttribute('aria-hidden', 'false');
const firstFocusable = modal.querySelector('button, [href], input, select, textarea');
if (firstFocusable) firstFocusable.focus();
const releaseFocus = trapFocus(modal);
modal.releaseFocus = releaseFocus;
Closing the modal:
cookieRemodal.closeModal();
const modal = cookieRemodal.element;
document.querySelectorAll('body > :not(#' + cookieRemodal.elementId + ')').forEach(el => {
el.removeAttribute('inert');
});
modal.setAttribute('aria-hidden', 'true');
if (modal.releaseFocus) modal.releaseFocus();
8. Footer
Each distinct group of links in the footer should be wrapped in its own <nav> with a unique aria-label or aria-labelledby so screen reader users can tell the sections apart. Contact information should be in <address>.
<nav class="footer__menu" aria-labelledby="footer-menu-heading">
<h3 id="footer-menu-heading" class="footer__heading">Services</h3>
<ul class="footer__list">
{foreach $services as $item}
<li class="footer__item">
<a class="footer__link" href="{$item|link}">
{$item|name|noescape}
</a>
</li>
{/foreach}
</ul>
</nav>
<address class="footer__address">
{$web['ADDRESS']|nl2br|noescape}
</address>
Icon-only links (social media, phone, etc.) that are rendered as CSS ::before pseudo-elements have no accessible text. If the icon is a real <img> or SVG, add aria-label to the link. If it's a ::before pseudo-element, add a visually hidden <span class="sr-only"> inside the link instead, since aria-label on icons served via CSS is not reliably announced.
Additional Reference
All remaining changes and further implementation examples can be found on our internal template website. If anything is unclear or not covered in this guide, the template web is the first place to check before reaching out.