Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 12 – Responsive design: media and container queries

Responsive design foundations

Set up the viewport meta tag, write mobile-first CSS with breakpoints taken from the content, and test reflow at 320 px, zoom and text spacing.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Set up the viewport meta tag and a mobile-first stylesheet
  • Design fluid layouts that meet WCAG reflow at 320 CSS px
  • Test layouts across widths, zoom levels and the WCAG text-spacing override

Before you start

What you’ll learn · Before you start

What you will learn

  • Set up the viewport meta tag and a mobile-first stylesheet
  • Design fluid layouts that meet WCAG reflow at 320 CSS px
  • Test layouts across widths, zoom levels and the WCAG text-spacing override

Before you start

On this page

A responsive page is one HTML document and one stylesheet that work at every width people use: a 320 px phone, a tablet turned on its side, a laptop zoomed to 200 %, a wide monitor. It rests on three habits. The viewport meta tag tells phones to lay the page out at their own width instead of a desktop’s. The stylesheet is written mobile first: flexible boxes that need no query at all, then min-width queries that add columns once the content has room for them. And the page is tested the way people read it: in narrow windows, zoomed in, and with their own text spacing, which WCAG 2.2 checks at level AA through success criteria 1.4.10 (Reflow) and 1.4.12 (Text Spacing). This lesson measures each step in Chromium 153, Firefox 155 and WebKit 26.6, with pages you can open in your own browser.

The viewport meta tag

Phones arrived when most pages were designed for desktop screens. So unless a page says otherwise, a mobile browser lays it out as if the screen were as wide as a small desktop window, then shrinks the result to fit. One line in the head turns that off. The two pages below differ only in that line, and each prints what the browser did:

The same page with and without the viewport tag

With the tag · viewport-set.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A page with the viewport tag</title>
  <style>
    body { margin: 0; padding: 1rem; font: 16px/1.5 Arial, sans-serif; }
    .note { padding: 1rem; background: #fff3c4; }
    @media (width <= 40rem) { .note { background: #c8f2d4; } }
  </style>
</head>
<body>
  <p class="note">This paragraph turns green when the layout is at most 40rem (640 px) wide.</p>
  <script>
    addEventListener('load', () => requestAnimationFrame(() => {
      const scale = window.visualViewport ? visualViewport.scale : 1;   // how much the browser shrank the layout to fit
      console.log(`layout ${document.documentElement.clientWidth} px wide · (width <= 40rem) ${matchMedia('(width <= 40rem)').matches} · shown at ${Math.round(scale * 100)} % · 16 px text looks ${(16 * scale).toFixed(1)} px`);
    }));
  </script>
</body>
</html>

Without the tag · viewport-missing.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A page without the viewport tag</title>
  <style>
    body { margin: 0; padding: 1rem; font: 16px/1.5 Arial, sans-serif; }
    .note { padding: 1rem; background: #fff3c4; }
    @media (width <= 40rem) { .note { background: #c8f2d4; } }
  </style>
</head>
<body>
  <p class="note">This paragraph turns green when the layout is at most 40rem (640 px) wide.</p>
  <script>
    addEventListener('load', () => requestAnimationFrame(() => {
      const scale = window.visualViewport ? visualViewport.scale : 1;   // how much the browser shrank the layout to fit
      console.log(`layout ${document.documentElement.clientWidth} px wide · (width <= 40rem) ${matchMedia('(width <= 40rem)').matches} · shown at ${Math.round(scale * 100)} % · 16 px text looks ${(16 * scale).toFixed(1)} px`);
    }));
  </script>
</body>
</html>

On a phone 360 CSS px wide, emulated in Chromium and WebKit:

With the tag Without the tag
layout width 360 px 980 px
(width <= 40rem) matches does not match
page shown at 100 % 37 %
16 px text looks 16 px 5.9 px

Both engines gave the same numbers. Without the tag, the page was laid out 980 px wide and drawn at 37 % of its size, so the query written for narrow screens never matched and the reader had to zoom in to read anything. Firefox’s desktop engine, which Playwright drives, laid out both pages 360 px wide, because desktop browsers ignore the tag.

width=device-width makes the layout viewport as wide as the screen in CSS px, which also sets what 100vw means. initial-scale=1 starts the page at 100 % zoom, so a browser doesn’t zoom out when something overflows. Leave out maximum-scale=1 and user-scalable=no: they ask the browser to stop pinch zoom, which people with low vision depend on, and WCAG’s success criterion 1.4.4 expects text to resize to 200 %. Safari on iOS ignores them, but other browsers may not.

Mobile first: start with one column

A mobile-first stylesheet starts with the layout for the narrowest screen, then lets each min-width query add something once the content fits. A desktop-first stylesheet starts with the widest layout and takes things away. The two files below give a grid of product cards the same layout at every width (all three engines placed the same columns at 320, 527, 528, 783 and 784 px):

One grid of cards, written two ways

Mobile first · mobile-first.css

/* Mobile first: the base styles are the one-column layout, and each query adds a column once the cards fit. The
   breakpoints come from the content (programs/columns_fit.mjs): a card needs 15rem, columns are 1rem apart and the
   page has 1rem of padding on each side. */
.page {
  padding-inline: 1rem;
}

.products {
  display: grid;
  gap: 1rem;
}

@media (width >= 33rem) {
  .products { grid-template-columns: repeat(2, 1fr); }
}

@media (width >= 49rem) {
  .products { grid-template-columns: repeat(3, 1fr); }
}

Desktop first · desktop-first.css

/* Desktop first: the same layout, written from the widest screen down. The base styles are three columns, and each
   query takes one away again. */
.page {
  padding-inline: 1rem;
}

.products {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(3, 1fr);
}

@media (width < 49rem) {
  .products { grid-template-columns: repeat(2, 1fr); }
}

@media (width < 33rem) {
  .products { grid-template-columns: 1fr; }
}

The mobile-first version is the safer one, for three reasons.

  1. The narrow layout needs the least code. Block boxes already stack and fill the width in normal flow, so one column needs almost nothing, and each query only adds. The desktop-first file sets three columns and then undoes them twice.
  2. What a browser shows when it skips a query. Browsers older than Chrome 104, Firefox 102 and Safari 16.4 don’t understand range syntax such as (width >= 33rem), so they skip those queries and keep the rest. The mobile-first file then falls back to one long column, which works at any width; the desktop-first file falls back to three columns, under 100 px each on a phone.
  3. Zoom is a narrow screen. Zooming in makes every CSS pixel bigger, so the layout gets fewer of them. This program applies the stylesheet’s two breakpoints to some common windows:
Which layout each screen and zoom level gets JavaScript · programs/layout_at.mjs
// Which layout the mobile-first stylesheet of this lesson shows. Zooming divides the width of the layout viewport:
// a window 1366 CSS px wide at 200 % zoom lays the page out 683 px wide, as a narrower screen would. The breakpoints
// are the stylesheet's min-width queries, 33rem and 49rem: 528 and 784 px at the default text size of 16 px.
const BREAKPOINTS = [
  { minRem: 33, layout: 'two columns' },
  { minRem: 49, layout: 'three columns' },
];

function layoutAt(cssWidth, textPx = 16) {
  let layout = 'one column'; // the base styles, written for the narrowest screens
  for (const b of BREAKPOINTS) if (cssWidth >= b.minRem * textPx) layout = b.layout;
  return layout;
}

// [what, window width at 100 % zoom in CSS px, zoom %]
const SCREENS = [
  ['phone', 360, 100],
  ['tablet', 768, 100],
  ['laptop', 1366, 100],
  ['laptop', 1366, 150],
  ['laptop', 1366, 200],
  ['desktop', 1920, 300],
  ['desktop', 1280, 400],
];

console.log('screen   window   zoom  layout width  layout');
for (const [what, windowPx, zoom] of SCREENS) {
  const width = windowPx / (zoom / 100);
  const shown = Number.isInteger(width) ? String(width) : width.toFixed(2);
  console.log(`${what.padEnd(9)}${String(windowPx).padStart(6)}  ${String(zoom).padStart(4)} %  ${shown.padStart(9)} px  ${layoutAt(width)}`);
}

Output

screen   window   zoom  layout width  layout
phone       360   100 %        360 px  one column
tablet      768   100 %        768 px  two columns
laptop     1366   100 %       1366 px  three columns
laptop     1366   150 %     910.67 px  three columns
laptop     1366   200 %        683 px  two columns
desktop    1920   300 %        640 px  two columns
desktop    1280   400 %        320 px  one column

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node layout_at.mjs

A laptop at 200 % gets the two-column layout, and a desktop at 400 % gets the phone’s one column. People who zoom see the layouts you wrote for small screens, so those layouts have to be complete, never a cut-down version of the page.

Phones, zoomed windows and desktops all come down to one layout width in CSS px, which the queries turn into one, two or three columns.Four screensThe layout width in CSS pxThe stylesheet's queries(width >= 33rem)(width >= 49rem)One, two or three columnsPhone with the viewport tag360 CSS pxPhone without the tag980 CSS px, shown at 37 %Laptop window 1366 px wideat 200 % zoom: 683 CSS pxDesktop window 1280 px wideat 400 % zoom: 320 CSS pxeach comes down tocompared withchooses

Every screen comes down to one layout width

Text description of the diagram

Four kinds of screen point to the same box, the layout width in CSS px:

  1. A phone with the viewport tag: 360 CSS px.
  2. A phone without the tag: the page is laid out 980 px wide and shown at 37 % of its size.
  3. A laptop window 1366 px wide at 200 % zoom: 683 CSS px.
  4. A desktop window 1280 px wide at 400 % zoom: 320 CSS px.

The layout width is compared with the stylesheet's queries, (width >= 33rem) and (width >= 49rem), and they choose one, two or three columns. A zoomed desktop window and a phone get the same layout when their layout widths are the same.

Fluid layouts: nothing wider than the screen

Between breakpoints, a layout has to stretch and shrink on its own. Four habits keep every box inside a narrow screen:

  • Widths as limits. max-inline-size: 60rem lets a box shrink below 60rem; width: 960px doesn’t.
  • Images that shrink. max-inline-size: 100% with block-size: auto scales an image down to its container and keeps its shape.
  • Text that wraps. An email address or a long code has no place to break, so it needs overflow-wrap: anywhere.
  • Wide content in its own scroller. A table with many columns can scroll sideways inside a box with overflow-x: auto, while the page around it doesn’t.

Here is a page that breaks all four habits, and a small audit script that lists what makes a page scroll sideways. Run the script in the DevTools console with the window 320 CSS px wide, or load it at the end of a page, as here:

A fixed-width page and a reflow audit

Fixed-width page · legacy.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A fixed-width page</title>
  <style>
    body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
    .page { width: 960px; margin: 0 auto; }
    .menu { display: flex; gap: 24px; padding: 0; list-style: none; white-space: nowrap; }
    .banner { display: block; }
    table { border-collapse: collapse; }
    th, td { padding: 8px 16px; border: 1px solid #767676; text-align: start; }
  </style>
</head>
<body>
  <div class="page">
    <ul class="menu"><li><a href="#">Home</a></li><li><a href="#">New arrivals</a></li><li><a href="#">Men</a></li><li><a href="#">Women</a></li><li><a href="#">Kids</a></li><li><a href="#">Home and living</a></li><li><a href="#">Help</a></li></ul>
    <img class="banner" width="1200" height="400" alt="Festive sale: up to 40 % off"
      src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 400'%3E%3Crect width='1200' height='400' fill='%23f4d35e'/%3E%3C/svg%3E">
    <h1>Your orders</h1>
    <p class="help">Questions? Write to customer.support.international.orders@shop.example</p>
    <table>
      <tr><th>Order</th><th>Placed</th><th>Items</th><th>Total</th><th>Status</th><th>Invoice</th></tr>
      <tr><td>#40211</td><td>Yesterday</td><td>3</td><td>₹2,499</td><td>Out for delivery</td><td><a href="#">Download</a></td></tr>
    </table>
  </div>
  <script src="reflow-audit.js"></script>
</body>
</html>

reflow-audit.js

// A reflow check for WCAG 2.2 success criterion 1.4.10: run it with the window 320 CSS px wide (or 1280 px at
// 400 % zoom), in the DevTools console or at the end of a page. It lists what makes the page scroll sideways: boxes
// that stick out of their container past the edge of the viewport, and text that cannot wrap to fit its box. Content
// in a scroll container of its own, such as a wide data table in a box with overflow-x: auto, is left out: content
// that needs two dimensions to make sense may scroll both ways.
function reflowAudit() {
  const limit = document.documentElement.clientWidth;
  const pageWidth = document.documentElement.scrollWidth;
  const scrolls = (el) => !['visible', 'clip'].includes(getComputedStyle(el).overflowX);
  const insideScroller = (el) => {
    for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) if (scrolls(a)) return true;
    return false;
  };
  // A block with its own text: inline boxes such as links have no width of their own to compare with.
  const ownText = (el) => getComputedStyle(el).display !== 'inline' && [...el.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim());
  const name = (el) => el.tagName.toLowerCase() + (el.classList.length ? '.' + el.classList[0] : '');
  const problems = [];
  for (const el of document.body.querySelectorAll('*')) {
    if (el.tagName === 'SCRIPT' || insideScroller(el)) continue;
    const box = el.getBoundingClientRect();
    const parent = el.parentElement.getBoundingClientRect();
    if (box.right > limit + 0.5 && box.right > parent.right + 0.5) {
      problems.push(`${name(el)} sticks out of its container and ends at ${Math.round(box.right)} px`);
    } else if (ownText(el) && el.scrollWidth > el.clientWidth + 1 && box.left + el.scrollWidth > limit + 0.5) {
      problems.push(`${name(el)} holds text that cannot wrap: ${el.scrollWidth} px in a ${el.clientWidth} px box`);
    }
  }
  console.log(`viewport ${limit} px, page ${pageWidth} px wide${pageWidth > limit ? ': it scrolls sideways' : ''}`);
  for (const p of problems) console.log(`  ${p}`);
  return problems.length;
}
reflowAudit();

The audit at 320 px, in all three engines:

Page What the audit listed
fixed-width page the .page wrapper ending at 960 px, the banner at 1200 px
the wrapper fixed with width: auto; max-inline-size: 60rem; padding-inline: 1rem three menu items ending at 360, 499 and 555 px, the banner at 1216 px, the email address (380 px of text in a 288 px box), the table at 554 px
reflowed page nothing: the page is 320 px wide

The second row is the usual experience: the outer box hides the problems inside it, and each fix uncovers the next. The banner moved to 1216 px because of the new 16 px of padding before it. All three engines listed the same boxes at the same pixels.

The same page, reflowed HTML · reflowed.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The same page, reflowed</title>
  <style>
    body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
    .page { max-inline-size: 60rem; margin-inline: auto; padding-inline: 1rem; }
    .menu { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; padding: 0; list-style: none; }
    .banner { display: block; max-inline-size: 100%; block-size: auto; }
    p { max-inline-size: 65ch; }
    .help { overflow-wrap: anywhere; }
    .table-scroll { overflow-x: auto; }
    table { border-collapse: collapse; }
    th, td { padding: 8px 16px; border: 1px solid #767676; text-align: start; white-space: nowrap; }
  </style>
</head>
<body>
  <div class="page">
    <ul class="menu"><li><a href="#">Home</a></li><li><a href="#">New arrivals</a></li><li><a href="#">Men</a></li><li><a href="#">Women</a></li><li><a href="#">Kids</a></li><li><a href="#">Home and living</a></li><li><a href="#">Help</a></li></ul>
    <img class="banner" width="1200" height="400" alt="Festive sale: up to 40 % off"
      src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 400'%3E%3Crect width='1200' height='400' fill='%23f4d35e'/%3E%3C/svg%3E">
    <h1>Your orders</h1>
    <p class="help">Questions? Write to customer.support.international.orders@shop.example</p>
    <div class="table-scroll" role="region" aria-label="Your orders" tabindex="0">
      <table>
        <tr><th>Order</th><th>Placed</th><th>Items</th><th>Total</th><th>Status</th><th>Invoice</th></tr>
        <tr><td>#40211</td><td>Yesterday</td><td>3</td><td>₹2,499</td><td>Out for delivery</td><td><a href="#">Download</a></td></tr>
      </table>
    </div>
  </div>
  <script src="reflow-audit.js"></script>
</body>
</html>

The fixed page keeps the menu on several lines with flex-wrap, scales the banner, lets the address break, limits paragraphs to 65ch so lines stay readable on a 1440 px screen, and moves the table into a scroll region. The region has tabindex="0", so keyboard users can reach it and scroll it with the arrow keys, and a role and an aria-label, so screen readers announce what it holds.

Breakpoints come from the content

Device widths make poor breakpoints: there are hundreds of them, they change every year, and windows, split screens and zoom produce every width in between. Take the breakpoints from the content instead. Widen a narrow window slowly, and add a breakpoint where the layout starts to look wrong or where it has room for more. For a grid of cards, that width can be worked out:

Where one more column of cards fits JavaScript · programs/columns_fit.mjs
// Breakpoints from the content: from what width does a grid of product cards have room for one more column? A card
// needs at least 15rem to show its photo, name and price; columns are 1rem apart and the page has 1rem of padding on
// each side. n columns first fit at n × card + (n − 1) × gap + 2 × padding.
const REM = 16; // px, the default text size
const card = 15 * REM;
const gap = 1 * REM;
const padding = 1 * REM;

for (let n = 1; n <= 4; n++) {
  const px = n * card + (n - 1) * gap + 2 * padding;
  console.log(`${n} column${n === 1 ? ' fits' : 's fit'} from ${px} px = ${px / REM}rem`);
}

Output

1 column fits from 272 px = 17rem
2 columns fit from 528 px = 33rem
3 columns fit from 784 px = 49rem
4 columns fit from 1040 px = 65rem

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node columns_fit.mjs

These cards need 15rem each, so two columns fit from 33rem (528 px) and three from 49rem (784 px), the two queries of the mobile-first file. Had the second column waited for 768 px, the md breakpoint of Bootstrap 5.3 and Tailwind CSS 4, a 600 px window would have shown one column of cards 568 px wide, more than twice what a card needs. Write breakpoints in rem, so they move with the reader’s default text size: the media queries lesson next in this module shows how browsers resolve them. A grid with repeat(auto-fill, minmax(15rem, 1fr)) finds the same widths with no query at all, as the intrinsic grids lesson shows, and the last lesson of this module builds whole layouts that way.

Test the way people read

Responsive testing has four parts, and WCAG 2.2 sets the numbers for three of them.

  1. Width. Success criterion 1.4.10 (Reflow) asks that content work at 320 CSS px wide without scrolling in two directions, which is a 1280 px window at 400 % zoom. Content that reads sideways, such as vertical text, gets the same rule at 256 CSS px tall. Parts that need two dimensions to make sense, such as data tables, maps, diagrams, video and games, may scroll both ways, but the text around them must still reflow.
  2. Zoom. Zoom a desktop browser to 200 % and 400 %. Success criterion 1.4.4 (Resize Text) asks that text can grow to 200 % without anything being lost, and zooming, as the program above showed, also moves the page through its narrower layouts.
  3. Text spacing. Success criterion 1.4.12 asks that nothing is lost when a reader sets line height to 1.5 times the font size, the space after paragraphs to 2 times, letter spacing to 0.12 times and word spacing to 0.16 times. Pages don’t have to use those values, only survive them.
  4. Real input. Touch, a keyboard and a screen reader on at least one real phone, because emulation shows sizes but not how a page feels under a thumb.
Responsive Design Tester See your page at phone, tablet and desktop widths side by side, including a custom 320 px frame.

The text-spacing test is a stylesheet. Add it as a user stylesheet, paste it into DevTools or load it with a bookmarklet:

The WCAG text-spacing test CSS · text-spacing.css
/* The WCAG 2.2 text-spacing test (success criterion 1.4.12). Add it as a user stylesheet, paste it into DevTools or
   load it with a bookmarklet, then check that no text is cut off and none overlaps other content. */
* {
  line-height: 1.5 !important;
  letter-spacing: 0.12em !important;
  word-spacing: 0.16em !important;
}
p {
  margin-block-end: 2em !important;
}

Components with fixed sizes are what it breaks. This page holds a card, a button and a tab twice: first with sizes fixed to fit the text as designed, then with the same sizes as minimums. It prints where text reaches past the edge of a box that hides its overflow:

Fixed sizes and minimums under the text-spacing test HTML · spacing.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Fixed sizes and the text-spacing test</title>
  <style>
    body { margin: 0; padding: 1rem; font: 16px/1.25 Arial, sans-serif; }
    .card { padding: 12px; border: 1px solid #767676; margin-block-end: 1rem; }
    .card h2 { margin: 0 0 8px; font-size: 1.25rem; }
    .card p { margin: 0; }
    .button { display: inline-block; padding-inline: 16px; border: 2px solid #1d4ed8; color: #1d4ed8; }
    .tab { display: inline-block; text-align: center; border-block-end: 3px solid #1d4ed8; }

    /* Sizes fixed to fit the text as it was designed */
    .fixed .card { height: 76px; overflow: hidden; }
    .fixed .button { width: 160px; height: 36px; line-height: 36px; white-space: nowrap; overflow: hidden; }
    .fixed .tab { width: 96px; white-space: nowrap; overflow: hidden; }

    /* The same components with minimums: they grow with their text */
    .flexible .card { min-block-size: 76px; }
    .flexible .button { min-inline-size: 160px; min-block-size: 36px; padding-block: 6px; }
    .flexible .tab { min-inline-size: 96px; padding-inline: 8px; }
  </style>
</head>
<body>
  <section class="fixed">
    <div class="card"><h2>Free delivery</h2><p>On orders above ₹499 to most pin codes in India, with returns within seven days.</p></div>
    <a class="button" href="#">Shop the festive sale</a>
    <span class="tab">Bestsellers</span>
  </section>
  <section class="flexible">
    <div class="card"><h2>Free delivery</h2><p>On orders above ₹499 to most pin codes in India, with returns within seven days.</p></div>
    <a class="button" href="#">Shop the festive sale</a>
    <span class="tab">Bestsellers</span>
  </section>
  <script>
    // Text is cut off when it reaches past the edge of a box that hides its overflow.
    function measure() {
      for (const section of document.querySelectorAll('section')) {
        const report = [...section.querySelectorAll('.card, .button, .tab')].map((el) => {
          const range = document.createRange();
          range.selectNodeContents(el);
          const text = range.getBoundingClientRect();
          const box = el.getBoundingClientRect();
          const css = getComputedStyle(el);
          const below = Math.round(text.bottom - (box.bottom - parseFloat(css.borderBottomWidth)));
          const beside = Math.round(text.right - (box.right - parseFloat(css.borderRightWidth)));
          if (below > 0) return `${el.className}: text ${below} px past the bottom edge`;
          if (beside > 0) return `${el.className}: text ${beside} px past the side`;
          return `${el.className} fits, ${Math.round(box.width)} × ${Math.round(box.height)} px`;
        });
        console.log(`${section.className}: ${report.join(' · ')}`);
      }
    }
    measure();
  </script>
</body>
</html>

At 360 px wide, before and after the test stylesheet:

Component Fixed sizes, before Fixed sizes, after Minimums, after
card, height: 76px fits text 22 px past the bottom edge fits, now 168 px tall
button, width: 160px fits text 21 px past the side fits, now 233 px wide
tab, width: 96px fits text 3 px past the side fits, now 115 px wide

All three engines measured the same pixels. Wider letters and taller lines need room, so the fix is the same in every row: give components minimum sizes and padding, and let their content decide the rest.

Practise: reflow and spacing checks

Exercise · Easy · JavaScript

Write the checks behind a reflow and text-spacing test

A reflow test needs three numbers: how wide the layout is at a zoom level, which boxes stick out past the screen, and how much spacing the WCAG text-spacing test adds. Write three functions in checks.mjs and export them.

- layoutWidth(windowWidth, zoom) returns the width of the layout viewport, in CSS px, of a window that is windowWidth CSS px wide at 100 % and zoomed to zoom per cent, rounded to two decimals. A window 1280 px wide at 400 % lays the page out 320 px wide. - spacingOverride(fontSize) returns the four values of WCAG 2.2 success criterion 1.4.12 for text of fontSize px, as { lineHeight, paragraphSpacing, letterSpacing, wordSpacing } in px, each rounded to two decimals: 1.5, 2, 0.12 and 0.16 times the font size. - reflowProblems(boxes, viewportWidth) takes boxes as { name, right, inScroller }: where each box ends, in px from the left edge of the viewport, and whether it sits inside a scroll container of its own. It returns the names of the boxes that end more than half a pixel past viewportWidth, in the order given, leaving out the boxes inside a scroll container: content such as a data table may scroll both ways in a box of its own.

The sample tests import the three functions from checks.mjs and run in your browser.

Starter code · checks.mjs

/** The width of the layout viewport, in CSS px, of a window `windowWidth` px wide zoomed to `zoom` %. */
export function layoutWidth(windowWidth, zoom) {
  // Replace this line with your code.
  return windowWidth;
}

/** The WCAG 2.2 text-spacing values, in px, for text of `fontSize` px. */
export function spacingOverride(fontSize) {
  // Replace this line with your code.
  return { lineHeight: 0, paragraphSpacing: 0, letterSpacing: 0, wordSpacing: 0 };
}

/** The names of the boxes that stick out past the viewport, leaving out those inside a scroll container. */
export function reflowProblems(boxes, viewportWidth) {
  // Replace this line with your code.
  return [];
}
The sample tests · checks.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { layoutWidth, spacingOverride, reflowProblems } from './checks.mjs';

test('zoom divides the width of the layout', () => {
  assert.equal(layoutWidth(1280, 400), 320);
  assert.equal(layoutWidth(1366, 200), 683);
  assert.equal(layoutWidth(360, 100), 360);
});

test('the layout width is rounded to two decimals', () => {
  assert.equal(layoutWidth(1366, 150), 910.67);
  assert.equal(layoutWidth(1536, 300), 512);
  assert.equal(layoutWidth(1000, 300), 333.33);
});

test('the four text-spacing values for 16 px text', () => {
  assert.deepEqual(spacingOverride(16), { lineHeight: 24, paragraphSpacing: 32, letterSpacing: 1.92, wordSpacing: 2.56 });
});

test('the text-spacing values follow the font size', () => {
  assert.deepEqual(spacingOverride(18), { lineHeight: 27, paragraphSpacing: 36, letterSpacing: 2.16, wordSpacing: 2.88 });
  assert.deepEqual(spacingOverride(15), { lineHeight: 22.5, paragraphSpacing: 30, letterSpacing: 1.8, wordSpacing: 2.4 });
});

test('boxes that end past the viewport are problems, in order', () => {
  const boxes = [
    { name: 'header', right: 320, inScroller: false },
    { name: 'banner', right: 1216, inScroller: false },
    { name: 'menu', right: 555, inScroller: false },
    { name: 'footer', right: 300, inScroller: false },
  ];
  assert.deepEqual(reflowProblems(boxes, 320), ['banner', 'menu']);
});

test('half a pixel of rounding is not a problem', () => {
  assert.deepEqual(reflowProblems([{ name: 'card', right: 320.5, inScroller: false }], 320), []);
  assert.deepEqual(reflowProblems([{ name: 'card', right: 320.75, inScroller: false }], 320), ['card']);
});

test('content in a scroll container of its own is left out', () => {
  const boxes = [
    { name: 'table', right: 554, inScroller: true },
    { name: 'email', right: 412, inScroller: false },
  ];
  assert.deepEqual(reflowProblems(boxes, 320), ['email']);
  assert.deepEqual(reflowProblems([], 320), []);
});
A hint

At 400 % every CSS pixel takes four times the space, so the layout gets a quarter of the window: divide by zoom / 100. Round with Math.round(value * 100) / 100. For the boxes, filter keeps the ones that end after viewportWidth + 0.5 and are not in a scroller, and map turns them into their names.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A phone 360 CSS px wide opens a page that has no viewport meta tag. How wide is the layout in Chromium and WebKit?

    Choose one answer.

    Show the answer to question 1

    Answer: 980 px, shown at about 37 % of its size

    Without the tag, mobile browsers lay a page out as if the screen were as wide as a small desktop window, then shrink it to fit. In the lesson's run both engines used 980 px and showed the page at 37 %, so 16 px text looked 5.9 px tall and the page's (width <= 40rem) query never matched.

  2. Question 2 of 5 A desktop window is 1440 CSS px wide at 100 % zoom. How wide, in CSS px, is the layout viewport when the reader zooms to 400 %?

    Type a number.

    Show the answer to question 2

    Answer: 360 px

    Zooming makes every CSS pixel bigger, so fewer of them fit across the window: 1440 ÷ 4 = 360. The page gets the layout its stylesheet gives a 360 px phone, which is why a mobile-first stylesheet also serves people who zoom.

  3. Question 3 of 5 Which of these make a page scroll sideways at 320 CSS px and fail WCAG 2.2 success criterion 1.4.10 (Reflow)?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • A banner image 1200 px wide with no max-inline-size: 100%
    • A page wrapper with width: 960px
    • An email address in a paragraph that cannot wrap

    A fixed width, an image at its own size and text that cannot wrap all push the page wider than the screen. A data table needs two dimensions to make sense, so the success criterion lets it scroll both ways inside a scroller of its own, as long as the rest of the page reflows. A maximum such as 65ch only limits wide screens.

  4. Question 4 of 5 The WCAG text-spacing test sets letter spacing to 0.12 times the font size. How much letter spacing, in px, must 20 px text cope with?

    Type a number.

    Show the answer to question 4

    Answer: 2.4 px (anything from 2.39 to 2.41 counts)

    0.12 × 20 = 2.4 px between letters. The other values for 20 px text are a line height of 30 px, 40 px after each paragraph and 3.2 px between words. A label in a box with a fixed width runs out of room first.

  5. Question 5 of 5 A browser too old to understand range syntax loads a stylesheet whose columns are added in @media (width >= 33rem) and @media (width >= 49rem). What does it show on a wide screen?

    Choose one answer.

    Show the answer to question 5

    Answer: The one-column base layout, because it skips the queries it cannot read

    A media query a browser cannot parse becomes not all, so its rules never apply, and the rest of the stylesheet still works. Written mobile first, the fallback is one column, which works at any width. Written desktop first with (width < 49rem), the same old browser would show three columns on a narrow phone.

Interview questions

Fresher · What does the viewport meta tag do, and what happens on a phone without it?

<meta name="viewport" content="width=device-width, initial-scale=1"> tells a mobile browser to make the layout viewport as wide as the screen in CSS px and to start at 100 % zoom. Without it, mobile browsers assume the page was designed for desktops: they lay it out about 980 px wide and shrink the result to fit, so text is drawn tiny and the reader has to pinch to zoom. Media queries written for narrow screens never match, because the layout is 980 px wide; on a 360 px phone the page is shown at about 37 %. Desktop browsers ignore the tag. Don’t add user-scalable=no or maximum-scale=1, which try to stop people from zooming.

Key takeaways

  • Put <meta name="viewport" content="width=device-width, initial-scale=1"> in every page; without it, a 360 px phone laid the page out 980 px wide and showed it at 37 %.
  • Write mobile first: one column needs almost no CSS, and min-width or width >= queries add columns when they fit.
  • A zoomed window is a narrow screen: 1280 px at 400 % is the 320 px of WCAG’s reflow criterion.
  • Use limits instead of fixed widths, shrinking images, overflow-wrap: anywhere for long words, and scroll regions for tables.
  • Take breakpoints from the content, in rem: where the layout breaks or has room for more, not from a list of devices.
  • Test at 320 px, at 200 % and 400 % zoom, and with the WCAG text-spacing stylesheet; give components minimum sizes so they grow with their text.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.