Skip to main content
Published / updated

Responsive Design

Before you start

You need: CSS layout (Article 05).

Time: about 45 minutes, plus the practice.

Learning objective

Build a page that works from 320px to a wide monitor without a separate mobile site or a pile of breakpoints.

Topics

  • Mobile-first and why
  • Media queries and choosing breakpoints
  • Fluid sizing without media queries
  • Responsive typography
  • Responsive images
  • Responsive tables and navigation
  • Touch targets
  • User preference queries
  • Container queries
  • Testing

Mobile-first

Write the mobile layout as the base, then add complexity with min-width queries.

/* Base: mobile. No media query. */
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}

@media (min-width: 640px) {
.card-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1024px) {
.card-grid { grid-template-columns: repeat(3, 1fr); }
}

Compare with desktop-first:

/* Base: desktop */
.card-grid { grid-template-columns: repeat(3, 1fr); }

@media (max-width: 1023px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 639px) { .card-grid { grid-template-columns: 1fr; } }

Both work. Mobile-first is better for three reasons:

  • The base case is the constrained one. A layout that works at 320px almost always works when given more room; the reverse is not true.
  • Mobile devices load less CSS. They match fewer rules and never parse the overrides.
  • It forces content priority. Deciding what matters on a small screen produces a better page on every screen.

Never mix min-width and max-width in the same stylesheet without a reason. Pick one direction and hold to it.

Media queries

@media (min-width: 640px) { }
@media (max-width: 639px) { }
@media (min-width: 640px) and (max-width: 1023px) { }
@media (orientation: landscape) { }
@media print { }

Common breakpoints, matched roughly to device classes:

/* 0–639 phone (base, no query) */
@media (min-width: 640px) { } /* large phone, small tablet */
@media (min-width: 768px) { } /* tablet */
@media (min-width: 1024px) { } /* laptop */
@media (min-width: 1280px) { } /* desktop */

Do not choose breakpoints from a device list. Devices change; your content does not. Widen the browser slowly and add a breakpoint at the width where the layout starts looking wrong. That usually produces three or four, not eight.

Use rem in queries so they respect the user's font size:

@media (min-width: 40rem) { } /* 640px at a 16px root */

max-width should use a fractional value to avoid a one-pixel gap where neither query matches:

@media (max-width: 639.98px) { }

print

@media print {
nav, .sidebar, .no-print { display: none; }

body { font-size: 12pt; color: #000; background: #fff; }

a[href^="http"]::after { content: " (" attr(href) ")"; }

table { page-break-inside: avoid; }
h2 { page-break-after: avoid; }
}

Worth doing for anything a school will actually print — a fee receipt, a result sheet, an attendance register. Printing link URLs matters because a printed link is otherwise useless.

Fluid sizing without media queries

Often the better answer.

/* Responsive with no breakpoints at all */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}

.container {
width: min(100% - 2rem, 1200px);
margin-inline: auto;
}

.sidebar-layout {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}

.sidebar-layout > .sidebar { flex: 1 1 240px; }
.sidebar-layout > .main { flex: 999 1 60%; }

min(), max() and clamp() do a great deal of responsive work:

width: min(100%, 1200px); /* whichever is smaller */
padding: max(1rem, 3vw); /* whichever is larger */
font-size: clamp(1rem, 2.5vw, 1.5rem); /* min, preferred, max */

clamp(min, preferred, max) scales between two bounds — the preferred value is used only while it falls between them.

A layout with fewer breakpoints is easier to maintain. Reach for auto-fit, minmax and clamp first, and add a media query only when the layout must genuinely restructure.

Responsive typography

:root {
--font-size-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--font-size-h1: clamp(1.75rem, 1.4rem + 1.75vw, 3rem);
--font-size-h2: clamp(1.375rem, 1.2rem + 0.9vw, 2rem);
}

body { font-size: var(--font-size-base); line-height: 1.6; }
h1 { font-size: var(--font-size-h1); line-height: 1.15; }
h2 { font-size: var(--font-size-h2); line-height: 1.25; }

p { max-width: 65ch; }

Text scales smoothly between the bounds with no breakpoints and no jumps.

Include a rem component in the clamp preferred value0.95rem + 0.25vw rather than 2.5vw alone. A purely viewport-based size ignores the user's browser font setting entirely, which is an accessibility failure. Mixing in rem keeps zoom working.

max-width: 65ch keeps lines readable. A paragraph running the full width of a wide monitor is hard to read regardless of font size.

Responsive images

<img src="/img/campus-800.jpg"
srcset="/img/campus-400.jpg 400w,
/img/campus-800.jpg 800w,
/img/campus-1600.jpg 1600w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
width="800" height="450"
alt="NexCoding Academy main building"
loading="lazy">
img { max-width: 100%; height: auto; }

srcset lists what is available; sizes says how wide it will render. The browser picks the smallest sufficient file — a real saving on a mobile connection.

For a genuinely different crop per screen size:

<picture>
<source media="(min-width: 1024px)" srcset="/img/hero-wide.jpg">
<source media="(min-width: 640px)" srcset="/img/hero-medium.jpg">
<img src="/img/hero-narrow.jpg" alt="NexCoding Academy campus" width="640" height="800">
</picture>

<picture> is for art direction — a different composition, not just a different size. For the same image at different resolutions, srcset is simpler.

The <img> inside <picture> is required and carries the alt.

Responsive tables

A wide data table is the hardest responsive problem. Three approaches, in order of preference.

Scroll it. Simplest, keeps the table a table, and stays accessible:

<div class="table-scroll" tabindex="0" role="region" aria-label="Student results">
<table>...</table>
</div>
.table-scroll { overflow-x: auto; }

tabindex="0" makes the scrolling region keyboard-focusable, so a keyboard user can scroll it. Without it the content is unreachable.

Hide less important columns:

@media (max-width: 640px) {
.col-optional { display: none; }
}

Honest about the trade — the data is gone on mobile, so only hide what genuinely does not matter there.

Restack as cards:

@media (max-width: 640px) {
table, thead, tbody, tr, th, td { display: block; }

thead { position: absolute; left: -9999px; }

tr { border: 1px solid var(--colour-border); margin-bottom: 1rem; padding: 0.5rem; }

td { padding-left: 45%; position: relative; }

td::before {
content: attr(data-label);
position: absolute;
left: 0.5rem;
font-weight: 600;
}
}
<td data-label="Roll number">NCA-2024-0012</td>

Looks good and destroys the table semanticsdisplay: block removes the table roles, so a screen reader no longer announces headers with cells. Use it only when the visual gain outweighs that, and prefer scrolling.

Responsive navigation

<nav aria-label="Main">
<button type="button" class="nav-toggle"
aria-expanded="false" aria-controls="navMenu">
Menu
</button>

<ul id="navMenu" class="nav-menu">
<li><a href="/students/">Students</a></li>
<li><a href="/teachers/">Teachers</a></li>
<li><a href="/fees/">Fees</a></li>
</ul>
</nav>
.nav-toggle { display: block; }
.nav-menu { display: none; }
.nav-menu[data-open="true"] { display: block; }

@media (min-width: 768px) {
.nav-toggle { display: none; }
.nav-menu { display: flex; gap: 1.5rem; }
}
const toggle = document.querySelector('.nav-toggle');
const menu = document.getElementById('navMenu');

toggle.addEventListener('click', () => {
const isOpen = toggle.getAttribute('aria-expanded') === 'true';

toggle.setAttribute('aria-expanded', String(!isOpen));
menu.dataset.open = String(!isOpen);
});

aria-expanded is what makes this accessible — a screen reader announces whether the menu is open. Without it the button is just "Menu" with no state, and the user cannot tell what pressing it did.

Touch targets

GuidelineMinimum
WCAG 2.1 AA24×24 CSS px
WCAG 2.2 AAA / Apple44×44
Material Design48×48
.button, .nav-menu a, .icon-button {
min-height: 44px;
min-width: 44px;
padding: 0.75rem 1rem;
}

.nav-menu li + li { margin-top: 0.5rem; }

A 20px icon button is genuinely difficult to hit on a phone. Expand the target without changing the visual size:

.icon-button {
position: relative;
}

.icon-button::after {
content: "";
position: absolute;
inset: -12px; /* extends the hit area beyond the visible button */
}

Spacing matters as much as size — adjacent small targets cause mis-taps even when each is large enough.

User preference queries

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

Some people experience nausea or migraine from motion. This respects the operating-system setting, and it is three lines. One of the rare justified uses of !important.

@media (prefers-color-scheme: dark) {
:root {
--colour-bg: #111827;
--colour-text: #f3f4f6;
--colour-muted: #9ca3af;
}
}

If every colour is a token, dark mode is one block redefining them.

@media (prefers-contrast: more) {
:root { --colour-border: #000; --colour-muted: #374151; }
}

Container queries

Media queries respond to the viewport. Container queries respond to the component's own width, which is what a reusable component actually needs.

.card-container {
container-type: inline-size;
container-name: card;
}

.card {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}

@container card (min-width: 400px) {
.card {
grid-template-columns: 120px 1fr;
}
}

The card becomes two-column when it is wide enough — whether it sits in a wide main area or a narrow sidebar. A media query cannot express that, because the viewport width says nothing about the space a component was given.

Container queries are supported in all current browsers and are the right tool for component-level responsiveness.

Testing

DevTools device toolbar (Ctrl+Shift+M) for layout. It changes the viewport and user-agent only — it does not reproduce real touch behaviour, real device performance, or real browser quirks.

Resize slowly from 320px upward. Watch for the width where the layout first breaks; that is where a breakpoint belongs.

Test at 320px. Still the narrowest common width, and the one most layouts fail at.

Zoom to 200%. WCAG requires content to remain usable. This catches fixed heights and absolute positioning that a viewport test misses.

Test on a real phone. Emulation misses touch target size in practice, real network speed, and how the on-screen keyboard covers a form field.

Check for horizontal scroll at every width:

* { outline: 1px solid red; }

Then look for the element extending past the viewport. The usual causes are a fixed width, a missing min-width: 0 on a grid or flex item, an image without max-width, or a long unbroken string.

Errors you will hit

What you seeCauseFix
Mobile shows a zoomed-out desktop pageMissing viewport meta tagAdd <meta name="viewport" content="width=device-width, initial-scale=1">
Horizontal scrollbar on mobileSomething has a fixed width wider than the screenFind it in DevTools
Media query never appliesWrong units, or wrong orderLater rules win at equal specificity
Text is unreadably small on a phoneFixed px sizesUse relative units
Layout breaks at 200% zoomFixed heightsLet content size itself

Without the viewport meta tag every media query is ignored on a phone — the browser pretends it is 980px wide and scales the whole page down.

Common mistakes

  • Desktop-first, so mobile is a stack of overrides
  • Breakpoints chosen from a device list rather than from the content
  • Mixing min-width and max-width queries
  • Media queries where auto-fit and minmax would do
  • font-size in vw alone, breaking zoom
  • No max-width: 100% on images
  • A scrollable table region with no tabindex, unreachable by keyboard
  • Restacking a table as cards and losing its semantics
  • A mobile menu toggle with no aria-expanded
  • Touch targets under 44px
  • Ignoring prefers-reduced-motion
  • Never testing at 320px or at 200% zoom
  • Treating device emulation as real device testing

Practice

The course exercises are recreate a responsive card layout and the responsive institute page assignment.

  1. Build a card grid mobile-first with three breakpoints. Then rebuild it with auto-fit and minmax and compare the CSS.
  2. Set heading sizes with clamp including a rem component. Zoom to 200% and confirm text still scales.
  3. Set one heading to font-size: 5vw with no rem. Zoom to 200% and record the difference.
  4. Add srcset and sizes to an image. Throttle to Slow 3G in DevTools and confirm which file is fetched at each width.
  5. Build the responsive nav with a toggle, aria-expanded and a breakpoint. Test it with a keyboard.
  6. Make a wide table scroll horizontally inside its container. Confirm the page body does not scroll sideways at 320px.
  7. Add tabindex="0" to the scroll region and confirm you can scroll it with arrow keys.
  8. Restack the same table as cards with data-label. Listen with a screen reader and record what is lost.
  9. Set every button to min-height: 44px. Test on a real phone and compare with a 24px version.
  10. Add prefers-reduced-motion and prefers-color-scheme blocks. Toggle both in your OS settings and confirm.
  11. Build a card with a container query so it goes two-column in main and one-column in the sidebar. Confirm a media query cannot achieve this.
  12. Set * { outline: 1px solid red } and find a deliberate horizontal overflow at 320px.

Exercise 11 is the one that shows why container queries exist.

You can now

  • Build a layout that works from 320px to a wide monitor
  • Write media queries that apply in the right order
  • Add the viewport meta tag and say what it does
  • Find the element causing a horizontal scrollbar
  • Respect a 200% zoom

Review questions

  1. Why is mobile-first better than desktop-first?
  2. Why must clamp for font size include a rem component?
  3. What does restacking a table as cards cost?
  4. What can a container query express that a media query cannot?

Next: JavaScript fundamentals