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 value — 0.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 semantics — display: 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
| Guideline | Minimum |
|---|---|
| WCAG 2.1 AA | 24×24 CSS px |
| WCAG 2.2 AAA / Apple | 44×44 |
| Material Design | 48×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 see | Cause | Fix |
|---|---|---|
| Mobile shows a zoomed-out desktop page | Missing viewport meta tag | Add <meta name="viewport" content="width=device-width, initial-scale=1"> |
| Horizontal scrollbar on mobile | Something has a fixed width wider than the screen | Find it in DevTools |
| Media query never applies | Wrong units, or wrong order | Later rules win at equal specificity |
| Text is unreadably small on a phone | Fixed px sizes | Use relative units |
| Layout breaks at 200% zoom | Fixed heights | Let 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-widthandmax-widthqueries - Media queries where
auto-fitandminmaxwould do font-sizeinvwalone, 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.
- Build a card grid mobile-first with three breakpoints. Then rebuild it with
auto-fitandminmaxand compare the CSS. - Set heading sizes with
clampincluding aremcomponent. Zoom to 200% and confirm text still scales. - Set one heading to
font-size: 5vwwith norem. Zoom to 200% and record the difference. - Add
srcsetandsizesto an image. Throttle to Slow 3G in DevTools and confirm which file is fetched at each width. - Build the responsive nav with a toggle,
aria-expandedand a breakpoint. Test it with a keyboard. - Make a wide table scroll horizontally inside its container. Confirm the page body does not scroll sideways at 320px.
- Add
tabindex="0"to the scroll region and confirm you can scroll it with arrow keys. - Restack the same table as cards with
data-label. Listen with a screen reader and record what is lost. - Set every button to
min-height: 44px. Test on a real phone and compare with a 24px version. - Add
prefers-reduced-motionandprefers-color-schemeblocks. Toggle both in your OS settings and confirm. - 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.
- 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
- Why is mobile-first better than desktop-first?
- Why must
clampfor font size include aremcomponent? - What does restacking a table as cards cost?
- What can a container query express that a media query cannot?
Next: JavaScript fundamentals