vidimus

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=no or maximum-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

KeyDefaultDescription
r12s.viewports[320, 375, 768]widths in px; each page is loaded at every one
r12s.minTarget24minimum tap target size and centre spacing in px
r12s.minFont12minimum font size in px
r12s.timeout60000ms to wait for a page's load event
r12s.concurrencyhalf 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 ignore rule matches the message, not the element, so { audit: 'r12s', message: '^font-size', where: '^/archive/' } silences small text on a section rather than one element.

Edit this page on GitHub