Responsive and Accessible Markup
The measurable contract a theme's markup must satisfy, and how each rule is proven.
Overview
Responsiveness and accessibility are not a review pass at the end. They are a few shell declarations plus rules you can measure on a live page.
Two obligations belong to the theme alone: the viewport declaration in every layout, and the root language and direction attributes.
Prerequisites
- A theme with at least one layout: any template that opens its own document head.
- Bootstrap 5 semantics. A version 4 class name is not an error: it is silently unstyled.
- A browser you can measure in.
Structure
Layout Declarations
Three declarations per layout; the engine adds none.
- The viewport meta, exactly
width=device-width, initial-scale=1. Without it a phone lays out at desktop width. - The root language and writing direction, from the engine's variables.
- The direction-aware stylesheet choice before first paint, inside the head guard.
Audit, per theme: grep -L 'name="viewport"' templates/website/<Theme>/layouts/*.tpl must print nothing.
Engine Variables
| Variable | Shape | Resolved from |
|---|---|---|
$ui_lang |
A language code, such as en or tr. |
The selected language, resolved again on every request. |
$ui_dir |
Exactly ltr or rtl, never anything else. |
The language pack's own direction flag. |
$setting |
Every manifest field, merged with saved values. | The settings schema; operator-owned layout switches only. |
Reference
Theme API
// coremio/classes/Theme.php
// Behind {asset path='...'}. Appends ?v= with the file's modification time for css and js ONLY.
// Fonts and images stay query-less on purpose: a versioned font preload would never match the
// query-less url() inside the font stylesheet, the preload is wasted and the face misses first paint.
public function assetUrl(string $path = ''): string;
// Behind {lang key='...' var='...'}. Every named argument except 'key' and 'g' becomes a
// {var} replacement in the value, which is how an accessible name carries a real number.
// Smarty only: the Twig function takes the key alone and forwards no replacements, so a
// Twig theme substitutes outside the call rather than shipping a literal {count} to a reader.
public function lang(string $key, array $vars = []): string;
// True when views/<view> exists in this theme, used to gate a shell before rendering into it.
public function viewExists(string $view): bool;
// coremio/classes/Language.php
public static function selected(): string;
public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool;
// The template only prints the result; it never resolves the direction itself.
$ui_lang = Language::selected();
$ui_dir = Language::g("package/rtl") ? 'rtl' : 'ltr';
Breakpoints
A fourth query is a decision, not a detail.
| Query | Tier | What belongs here |
|---|---|---|
max-width: 575.98px |
Below the small tier | Phone compaction: hiding a label, collapsing a toolbar. |
max-width: 767.98px |
Below the medium tier | Layout changes that outlive the phone. |
min-width: 992px |
Large and up | Desktop-only chrome. Written as a minimum, so the small screen is the default. |
print |
Chrome that has no meaning on paper. One block. |
Type Scale
A new size is never invented next to an existing one.
| Class | Value | At a 16px root | Use for |
|---|---|---|---|
fs-7 | 0.85rem | 13.6px | Secondary text: card bodies, list rows, table cells. |
fs-8 | 0.8rem | 12.8px | Hints and helper lines under a control. |
fs-9 | 0.7rem | 11.2px | The floor. Badges and micro labels only. |
| Headings | Bootstrap scale | The fifth heading step is 20px | Section titles. A helper line is never larger than its label. |
Motion and Focus
| Preference | What the theme does | How to prove it |
|---|---|---|
| Reduced motion | A global block clamps every animation to a hundredth of a millisecond and one iteration. | Count elements still at zero opacity; the answer must be zero. |
| Keyboard focus | Rings appear for keyboard traversal only. | Tab through: every stop must be visible. Then click: no ring may remain. |
Off-Screen Text
| Need | Correct markup | What the wrong one does |
|---|---|---|
| A control with no visible text | An aria label from the language file. | Hard-coded text stays English everywhere else. |
| Text for screen readers only | visually-hidden |
sr-only is the Bootstrap 4 name; Bootstrap 5 does not define it. A bundled icon library keeps it alive. |
| A landmark for keyboard users | A nav element with an aria label, plus the current page marked. | Unnamed navigation landmarks are indistinguishable in a landmark list. |
Wide Content
| Content | Wrapper | Without it |
|---|---|---|
| A table with more columns than a phone can show | A responsive table wrapper around the table element. | The page itself scrolls sideways, and every surface on it inherits that. |
| A long unbroken string (a key, a token, a domain) | Wrapping on the cell, not a width removal on the container. | Widening the container pushes the last columns past the edge. |
| A code block or payload | Its own scroll container. | The same page-level sideways scroll, on pages with a long line. |
Wide content scrolls inside its own box; the page body never does.
Example
The obligations, in layout-head order.
<html lang="{$ui_lang|default:'en'}" dir="{$ui_dir|default:'ltr'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{* Before first paint: resolve the stored direction and write the matching Bootstrap file,
so the first painted frame is already correct instead of flipping a frame later. *}
<script>
var storedDir = localStorage.getItem('acme-dir');
var rtl = storedDir ? storedDir === 'rtl' : document.documentElement.getAttribute('dir') === 'rtl';
document.documentElement.setAttribute('dir', rtl ? 'rtl' : 'ltr');
document.write('<link rel="stylesheet" href="' +
(rtl ? '{asset path="css/bootstrap.rtl.min.css"}' : '{asset path="css/bootstrap.min.css"}') + '">');
</script>
<link rel="stylesheet" href="{asset path='css/default.css'}">
</head>
The two markup patterns that carry most of the accessibility work.
<nav class="client-subnav" aria-label="{lang key='website/index/subnav-aria'}">
<ul>
<li>
<a class="client-subnav-link{if $subnav == 'services'} active{/if}"
{if $subnav == 'services'} aria-current="page"{/if}
href="{link route='services'}">
<i class="bi bi-hdd-stack"></i>{lang key='website/index/subnav-services'}
{if $client_badges.services > 0}
{* The number alone is meaningless out of context, so the badge names itself. *}
<span class="client-subnav-badge"
aria-label="{lang key='website/index/subnav-badge-services' count=$client_badges.services}">
{$client_badges_text.services}
</span>
{/if}
</a>
</li>
</ul>
</nav>
{* Bootstrap 5 name. 'sr-only' is not a Bootstrap 5 class; do not rely on it. *}
<span class="visually-hidden">{lang key='website/index/footer-payment-methods'}</span>
Those two variables arrive with the client page data, not from the engine.
services, domains, invoices, support. A key is zero rather than absent when the section is off.
The acceptance list, measured on a live page.
Pitfalls
Copied markup keeps working until the missing class was the one doing the work. Find the rule that styles it first.
The obligation is per layout, not per theme. A checkout shell without it puts the purchase flow at desktop width.
Removing a cell's width limit pushes the last columns off the edge. Let the value wrap instead.
Aria labels are the easiest strings to hard-code, because nobody sees them in review.
Everything here is behaviour, not visual identity. A fix in one theme only leaves the same defect elsewhere.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.