r12s: responsiveness
r12s (responsiveness, like a11y for accessibility) loads every page at several narrow viewport widths and measures the rendered layout. It finds the problems that only show on a phone: a table or code block that makes the whole page scroll sideways, links too small or too close together to tap, text too small to read, and a viewport meta tag that is missing or stops users from zooming.
Needs
npm i -D puppeteer
The audit loads pages over HTTP (requires: 'server'): vidimus serves the build locally, or uses --origin.
Run it
npx vidimus r12s
It is part of the default set.
What it checks
Every built page that is not excluded is loaded at every width in r12s.viewports, in every locale: translated text is often longer and breaks layouts that fit in the default language. The viewport is 800px tall, and widths below 768px are emulated as a mobile device with touch. After the load event vidimus waits for web fonts, then measures. If the page navigates away while being measured (a meta refresh or script redirect), it waits for the new page and measures that instead, once. With r12s.sample, pages whose URL path matches a pattern are reduced to the first matching page per pattern; pages that match no pattern are all loaded.
overflow
The page scrolls sideways: the document is more than 1px wider than the viewport. The finding lists up to five of the innermost elements that stick out past the end edge (the right edge, or the left one on a right-to-left page), with their width and right edge in px, so you see the element causing it rather than every ancestor. Elements inside a box that scrolls or clips them, such as code in a <pre> with overflow-x: auto, are not listed: they do not make the page scroll.
An <iframe>, <video>, <embed> or <object> with fixed width/height attributes (YouTube's default width="560") is the usual cause on phones; when one is listed, the fix suggests sizing it with CSS instead:
iframe { width: 100%; height: auto; aspect-ratio: 16 / 9; }
target-size
A link (a[href]), button, input, select, textarea or [role="button"] smaller than r12s.minTarget in either dimension, whose centre is closer than minTarget to the centre of another target. A small target with enough space around it passes, which approximates the spacing exception of WCAG 2.2 success criterion 2.5.8 (Target Size, Minimum). Elements that are not rendered (display: none on themselves or an ancestor), have no size, or are hidden by visibility: hidden or opacity: 0 on themselves or an ancestor are not counted. Inline links inside a sentence (an inline element whose parent also holds text) are exempt, as in WCAG 2.5.8.
font-size
An element that directly contains text (its first child is a non-blank text node) with a computed font-size below r12s.minFont. SVG content and elements that are not rendered or are hidden, as above, are skipped.
viewport
- No
<meta name="viewport">at all: the page renders at desktop width on phones. - A viewport meta that disables pinch zoom:
user-scalable=noormaximum-scale=1.
Grouping
Findings are grouped by rule, measurement and element, and list every width and page where the same defect occurs. A defect in a shared component shows once, with @320/375px for the widths and on: for the pages. Overflow is usually reported once per width, since its measurement includes the viewport width.
A page that fails to load within r12s.timeout becomes a failed to load /path/ finding, with the widths and the browser error as details; the other pages are still checked.
Example output
─── r12s ────────────────────────────────────────────────────────
✖ font-size @320/375/768px - 11px
small.byline «Photo: Jane Doe»
on: /blog/first-post/ /blog/second-post/ /fr/blog/premier-article/
→ Set the font-size of the element listed to at least 12px.
✖ overflow @320px - page scrolls sideways: 412px of content in a 320px viewport
pre.astro-code «npm create astro@latest -- --tem» [w=396 right=412]
on: /docs/install/
→ Constrain the widest element listed so it fits the viewport, e.g. with max-width:100% or overflow-wrap:anywhere.
✖ target-size @320/375px - 16x16px target, nearest neighbour centre 20.0px away
a.social.icon
on: / /about/ /contact/ +42 more
→ Make the element listed at least 24x24px, or add spacing so neighbouring target centres are 24px apart.
✖ viewport @320/375/768px - pinch zoom is disabled: "width=device-width, initial-scale=1, maximum-scale=1"
on: /landing/
→ Remove user-scalable=no and maximum-scale from the viewport meta so users can pinch-zoom.
✖ r12s: 4 distinct defects across 48 pages x 3 viewports (320, 375, 768px) (22.4s)
Elements are described as tag#id.class1.class2.class3 «first 32 characters of text». The summary of a passing run:
✔ r12s: 48 pages x 3 viewports (320, 375, 768px), no defects (19.0s)
Options
| Key | Default | Description |
|---|---|---|
r12s.viewports | [320, 375, 768] | widths in px; each page is loaded at every one |
r12s.minTarget | 24 | minimum tap target size and centre spacing in px |
r12s.minFont | 12 | minimum font size in px |
r12s.timeout | 60000 | ms to wait for a page's load event |
r12s.concurrency | half the cores (2 to 8) | browser tabs used at the same time |
r12s.exclude | [] | URL path patterns to skip, e.g. ^/print/ |
r12s.sample | [] | URL path patterns: load one page per matching template |
Config example
import { defineConfig } from 'vidimus';
export default defineConfig({
r12s: {
viewports: [320, 390, 820],
minTarget: 44,
exclude: ['^/slides/'],
},
});
minTarget: 44 matches the stricter WCAG 2.5.5 (Target Size, Enhanced) criterion.
Common fixes
Let wide content scroll inside its own box instead of the page:
<style>
pre, table { display: block; max-width: 100%; overflow-x: auto; }
img, video, iframe { max-width: 100%; height: auto; }
a, code { overflow-wrap: anywhere; }
</style>
Give icon links a tappable area without changing the icon size:
<style>
.social a { display: inline-grid; place-items: center; min-width: 24px; min-height: 24px; }
</style>
Use a viewport meta that allows zoom:
<meta name="viewport" content="width=device-width, initial-scale=1">
Tips
- Inline links inside a paragraph are measured too. If they sit on adjacent lines with a small
line-height, raise the line height. - Each defect is keyed on the element description, so the same problem on elements with different text (each post's byline) produces one finding per text. A top-level
ignorerule matches the message, not the element, so{ audit: 'r12s', message: '^font-size', where: '^/archive/' }silences small text on a section rather than one element.