Media Queries
📱 Chapter 7 — Media Queries and Responsive Layouts
Responsive design is the practice of making a single HTML document adapt to different screen sizes, device capabilities, and user preferences — without serving different HTML to different devices. The core tool is the CSS media query: a conditional block of CSS that applies only when specific conditions about the environment are true. This chapter covers the full media query syntax, the mobile-first philosophy, breakpoint strategy, preference-based queries, and responsive layout patterns that work in production.
1 — The Viewport Meta Tag (Non-Negotiable)
Before a single media query works on a mobile device, you need this tag in your HTML <head>:
<meta name="viewport" content="width=device-width, initial-scale=1">
width=device-width tells the browser to use the real physical screen width instead, making your media queries meaningful.
width=device-width— set the viewport to the device's actual width in CSS pixels.initial-scale=1— do not zoom in or out when the page first loads.- Do not add
user-scalable=no— it prevents users from zooming, which is an accessibility violation.
2 — @media Syntax
A media query wraps a block of CSS that only applies when its condition evaluates to true. The condition is made up of a media type (optional) and one or more media features in parentheses.
/* @media [type] [and (feature: value)] { declarations } */
@media screen and (min-width: 768px) {
/* applies on screens 768px or wider */
}
/* Media type is optional — defaults to 'all' */
@media (min-width: 768px) {
/* same effect — 'all' is the default type */
}
/* Print stylesheet — only when printing */
@media print {
.no-print { display: none; }
body { font-size: 12pt; color: #000; }
}
Media types
| Type | Applies to |
|---|---|
all | All devices (default when type is omitted) |
screen | Screens: computers, phones, tablets |
print | Print preview and printing |
speech | Screen readers (rarely used directly) |
3 — Width-Based Media Features
min-width and max-width are by far the most used media features. They test the viewport width — the width of the browser window, not the screen resolution.
/* min-width: applies when viewport is AT LEAST this wide */
@media (min-width: 600px) { /* 600px and wider */ }
/* max-width: applies when viewport is AT MOST this wide */
@media (max-width: 599px) { /* 599px and narrower */ }
/* Targeting a range (old syntax) */
@media (min-width: 600px) and (max-width: 959px) { /* tablet only */ }
/* Targeting a range (Level 4 range syntax — see Section 7) */
@media (600px <= width <= 959px) { /* same — cleaner */ }
/* height, orientation */
@media (min-height: 800px) { /* tall viewports */ }
@media (orientation: landscape) { /* wider than tall */ }
@media (orientation: portrait) { /* taller than wide */ }
4 — Mobile-First vs Desktop-First
min-width queries to progressively add complexity as the screen gets wider. Each breakpoint adds to what was already there.max-width queries to remove or simplify as the screen gets smaller. Each breakpoint takes away from the default.unset and none overrides.
5 — Breakpoint Strategy
Content-based vs device-based breakpoints
The most common mistake with breakpoints is choosing them by device ("768 for iPad", "375 for iPhone"). Devices change every year — your CSS should not be tied to 2024 screen sizes. Instead, choose breakpoints where your content breaks: drag the browser window narrow until the layout looks bad, then add a breakpoint there.
/* A reasonable starting set — adjust to suit your content */
@media (min-width: 480px) { /* large phones, landscape */ }
@media (min-width: 640px) { /* small tablets, phablets */ }
@media (min-width: 768px) { /* tablets */ }
@media (min-width: 1024px) { /* small laptops */ }
@media (min-width: 1280px) { /* desktops */ }
@media (min-width: 1536px) { /* wide screens */ }
/* You almost certainly don't need all six — start with two or three. */
/* Most layouts need: small (base) + medium (600-768) + large (960+). */
@media condition strings, but you can define them as reference tokens in a comment or use a preprocessor variable. In plain CSS, define breakpoints in one place (a :root comment block or a dedicated _breakpoints.css file) and never write the pixel values from memory — search-and-replace beats trying to remember whether you used 768 or 769.
6 — Logical Operators
/* and — ALL conditions must be true */
@media (min-width: 600px) and (orientation: landscape) {
/* 600px+ AND landscape orientation */
}
/* , (comma = OR) — ANY condition is true */
@media (max-width: 480px), (orientation: portrait) {
/* narrow screens OR portrait — either triggers this */
}
/* not — negates the entire query (not just the feature) */
@media not (hover: hover) {
/* devices without a hover capability (touch-only) */
}
/* Combining all three */
@media screen and (min-width: 1024px),
print and (min-resolution: 300dpi) {
/* wide screen OR high-res print */
}
not negates the entire query, not just the next condition. @media not screen and (min-width: 768px) is read as not (screen and (min-width: 768px)) — it applies to everything that is NOT (a screen wider than 768px). Wrap with parentheses in complex queries.7 — Level 4 Range Syntax
CSS Media Queries Level 4 introduced a cleaner range syntax using comparison operators. It is supported in all modern browsers since 2023 and eliminates the awkward min-width/max-width naming.
/* ── Old syntax ── */
@media (min-width: 600px) { }
@media (max-width: 599px) { }
@media (min-width: 600px) and (max-width: 959px) { }
/* ── New range syntax ── */
@media (width >= 600px) { }
@media (width < 600px) { }
@media (600px <= width < 960px) { }
/* Read the last one as: "when 600px ≤ width < 960px" */
/* The range syntax also supports height, aspect-ratio: */
@media (45rem <= width <= 80rem) { /* rem-based breakpoints */ }
rem-based breakpoints over px: if the user has set a larger browser default font size, rem breakpoints scale with it. 48rem ≈ 768px at the default 16px base, but scales proportionally with user font size preferences.8 — User Preference Queries
Beyond layout, media queries can detect user preferences set at the operating system level. These unlock genuinely responsive experiences — not just different layouts, but fundamentally different visual treatments based on what the user has told their device they need.
none = touch-only device. Use to show/hide hover-dependent UI.coarse = finger/stylus (needs bigger tap targets). fine = mouse/trackpad./* ── Dark mode ── */
:root {
--bg: #fff;
--text: #111;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0d0f18; --text: #e2e4ec; }
}
/* All elements that use var(--bg) and var(--text) switch automatically */
/* ── Reduced motion — IMPORTANT for accessibility ── */
.card {
transition: transform 0.3s ease;
}
.card:hover { transform: translateY(-4px); }
@media (prefers-reduced-motion: reduce) {
/* Replace motion with a simpler fade, or remove it */
.card { transition: opacity 0.15s; }
.card:hover { transform: none; opacity: 0.85; }
}
/* ── Touch targets for coarse pointer ── */
.btn { padding: 6px 14px; } /* default: fine pointer */
@media (pointer: coarse) {
.btn { padding: 12px 20px; min-height: 44px; } /* 44px = Apple's tap target minimum */
}
/* ── Hover: hide hover-only UI from touch devices ── */
.tooltip { display: none; }
@media (hover: hover) {
.tooltip-trigger:hover .tooltip { display: block; }
}
prefers-reduced-motion: reduce is set by real users who need it. Implementing it is a matter of basic accessibility — not just a nice-to-have enhancement.
prefers-color-scheme: dark, prefers-reduced-motion: reduce, and more without changing your OS settings. Firefox has the same in DevTools → Inspector → Responsive Design Mode.
9 — Responsive Layout Patterns
Pattern 1 — Stack to side-by-side
/* Mobile: stacked */
.page {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
@media (min-width: 720px) {
.page {
grid-template-columns: 1fr 300px;
grid-template-areas: "main sidebar";
}
}
Pattern 2 — Responsive navigation
/* Mobile: stacked links, hidden by default */
.nav__links {
display: none;
flex-direction: column;
}
.nav__links.open { display: flex; }
.nav__toggle { display: block; } /* hamburger button */
@media (min-width: 768px) {
.nav__links {
display: flex !important; /* always visible on desktop */
flex-direction: row;
}
.nav__toggle { display: none; } /* no hamburger on desktop */
}
!important on the desktop nav is one of the legitimate uses from Chapter 6: it prevents JS toggle logic (which sets display: flex via class) from accidentally hiding the nav when resizing from mobile to desktop with the menu open.Pattern 3 — Fluid typography
/* Approach 1: stepped — breakpoints change font size */
html { font-size: 16px; }
@media (min-width: 768px) { html { font-size: 17px; } }
@media (min-width: 1280px) { html { font-size: 18px; } }
/* Approach 2: fluid — clamp() (covered in Chapter 8) */
html {
font-size: clamp(16px, 1.2vw + 12px, 20px);
/* minimum 16px, grows with viewport, maximum 20px */
}
/* Approach 3: Tailwind-style — use modular scale per breakpoint */
h1 { font-size: 1.75rem; }
@media (min-width: 768px) {
h1 { font-size: 2.5rem; }
}
Pattern 4 — Responsive images
/* The universal image reset */
img {
max-width: 100%; /* never wider than its container */
height: auto; /* maintain aspect ratio */
display: block; /* eliminate inline-block gap below image */
}
/* Hide/show different art direction at breakpoints */
.hero-img-mobile { display: block; }
.hero-img-desktop { display: none; }
@media (min-width: 768px) {
.hero-img-mobile { display: none; }
.hero-img-desktop { display: block; }
}
/* Better: use the HTML <picture> element for art direction
CSS cannot change which image src the browser downloads.
<picture> tells the browser to pick the right source. */
max-width: 100%; height: auto to all images as a global reset — it is one of the most important two lines in any stylesheet. Without it, a 1200px-wide image renders at 1200px regardless of its container, causing horizontal overflow.10 — Quick Reference
Key media features
| Feature | Tests | Common values |
|---|---|---|
width / min-width / max-width | Viewport width | px, rem, em |
height / min-height / max-height | Viewport height | px, rem |
orientation | Portrait vs landscape | portrait · landscape |
prefers-color-scheme | OS colour mode | dark · light |
prefers-reduced-motion | Reduce motion setting | reduce · no-preference |
hover | Primary input can hover | hover · none |
pointer | Pointer precision | fine · coarse · none |
prefers-contrast | Contrast preference | more · less · no-preference |
Logical operators
| Operator | Meaning | Example |
|---|---|---|
and | All conditions true | (min-width: 600px) and (orientation: landscape) |
, (comma) | Any condition true (OR) | (max-width: 480px), (orientation: portrait) |
not | Negates the query | not (hover: hover) |
Old vs new range syntax
| Old (Level 3) | New (Level 4) |
|---|---|
(min-width: 600px) | (width >= 600px) |
(max-width: 959px) | (width < 960px) |
(min-width: 600px) and (max-width: 959px) | (600px <= width < 960px) |
✏️ Exercises
Write the CSS from scratch before checking the solution. Test in a browser by resizing the window — DevTools Responsive Mode (F12 → Toggle device toolbar) makes this easy.
min-width queries throughout. Include the viewport meta tag.grid-template-columns: 1fr as the base, then progressively add columns inside each min-width breakpoint. Only the column count and gap need to change — padding stays the same.<meta name="viewport" content="width=device-width, initial-scale=1">
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.card { padding: 20px; }
@media (min-width: 600px) {
.card-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 960px) {
.card-grid { grid-template-columns: repeat(3, 1fr); gap: 24px; }
}
@media (min-width: 1280px) {
.card-grid { grid-template-columns: repeat(4, 1fr); }
}
--bg, --surface, --text, and --accent in light mode on :root. Then write a prefers-color-scheme: dark query that redefines all four variables. Apply the variables to body, a .card component, and all a link elements.var() updates automatically. You don't need to repeat the full rules inside the query.:root {
--bg: #ffffff;
--surface: #f3f4f6;
--text: #111827;
--accent: #4f8ef7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0f18;
--surface: #1a1d27;
--text: #e2e4ec;
--accent: #7ab8ff;
}
}
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--surface);
color: var(--text);
}
a { color: var(--accent); }
.hero section with a large animated background gradient. Add a prefers-reduced-motion query that disables the animation entirely and substitutes a static background for users who have enabled Reduce Motion. Then add a hover: none query that removes a :hover scale transform from a .hero-btn inside the hero (touch devices cannot hover).animation: none inside the reduced-motion query to cancel any named keyframe animation. Use pointer-events: auto but remove the transform in the hover-none query.@keyframes gradientShift {
0% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
}
.hero {
background: linear-gradient(135deg, #1a2a4a, #2a1a4a, #1a3a2a);
background-size: 300% 300%;
animation: gradientShift 6s ease infinite alternate;
}
.hero-btn { transition: transform 0.2s; }
.hero-btn:hover { transform: scale(1.05); }
@media (prefers-reduced-motion: reduce) {
.hero {
animation: none;
background-size: 100% 100%; /* static gradient */
}
}
@media (hover: none) {
.hero-btn:hover { transform: none; }
}
min-width) that produce the same visual result. Then rewrite the range query using the Level 4 range syntax./* Desktop-first */
.grid { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 1023px) { .grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 767px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 479px) { .grid { grid-template-columns: 1fr; } }min-width breakpoint adds a column. For the range syntax, target the tablet range using 600px <= width < 960px./* Mobile-first — base is mobile */
.grid { grid-template-columns: 1fr; }
@media (min-width: 480px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 768px) { .grid { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(4, 1fr); } }
/* Level 4 range syntax — targeting the tablet range */
@media (768px <= width < 1024px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}