Chapter 7 — Deep Dive: Responsive Design
Responsive design is the practice of making a layout adapt to the space available
to it — whether that's a 320px phone, a 1440px desktop, or a 280px sidebar widget.
Modern CSS has three distinct tools for this: media queries (respond to the viewport),
container queries (respond to the component's own container), and intrinsic layouts
(respond to content without any query at all). Understanding when to use each is the
mark of a mature CSS practitioner.
1. The Viewport Meta Tag
Before any CSS responsive work can function on a mobile device, the correct viewport
meta tag must be in the <head>. Without it, mobile browsers render
the page at a virtual ~980px width and scale it down — your media queries fire at the
wrong sizes and everything looks zoomed out.
<!-- Required in every HTML page -- goes in <head> -->
<meta name="viewport" content="width=device-width, initial-scale=1">
/* width=device-width — use the actual device pixel width, not the virtual 980px */
/* initial-scale=1 — don't scale the page on first load */
/* Do NOT add: */
/* user-scalable=no — prevents pinch-zoom, accessibility violation */
/* maximum-scale=1 — same problem — also blocks browser text zoom */
Never disable user scaling. user-scalable=no and
maximum-scale=1 prevent users from zooming in — breaking WCAG 1.4.4
(Resize Text). Users with low vision depend on this. Both values are ignored by
iOS Safari since version 10, but they still harm accessibility on Android and desktop.
Never use them.
2. Media Queries in Depth
Syntax — old and new
/* Classic syntax (still fully valid) */
@media screen and (min-width: 768px) { }
@media screen and (max-width: 1023px) { }
@media screen and (min-width: 600px) and (max-width: 900px) { }
@media print { }
@media not screen { }
/* Modern range syntax (Chrome 104+, Firefox 63+, Safari 16.4+) */
@media (width >= 768px) { }
@media (width <= 1023px) { }
@media (600px <= width <= 900px) { }
/* Boolean logic */
@media (min-width: 600px) and (orientation: landscape) { }
@media (max-width: 400px), (min-width: 900px) { /* comma = OR */ }
@media not (hover: hover) { }
/* In <link> tags — loads CSS but always applies, just with different specificity timing */
/* <link rel="stylesheet" media="(min-width: 768px)" href="wide.css"> */
/* The file still downloads — only use for print stylesheets to avoid blocking */
Mobile-first vs desktop-first
/* MOBILE-FIRST: write base styles for small screens, add complexity up */
.nav {
display: flex;
flex-direction: column; /* mobile: stacked */
}
@media (min-width: 768px) {
.nav {
flex-direction: row; /* tablet+: horizontal */
}
}
/* DESKTOP-FIRST: write for large screens, subtract down */
.nav {
display: flex;
flex-direction: row;
}
@media (max-width: 767px) {
.nav {
flex-direction: column;
}
}
/* Mobile-first is recommended: simpler base styles, progressively enhanced. */
/* Network: simple CSS loads first, complex overrides only if needed. */
Common breakpoints — a practical reference
| Name | min-width | Targets | Notes |
| xs / base |
— |
Small phones (320–479px) |
The default — no query needed with mobile-first approach. |
| sm |
480px |
Large phones |
Side-by-side form fields, slightly wider cards. |
| md |
768px |
Tablets / large phones landscape |
Horizontal nav, two-column layouts. Most significant breakpoint. |
| lg |
1024px |
Small laptops |
Three-column layouts, sidebars appear. |
| xl |
1280px |
Desktops |
Full layouts, wider content areas. |
| 2xl |
1536px |
Large monitors |
Max-width containers become relevant here — constrain prose width. |
Design to content, not to device. The exact breakpoint values above
are conventions from frameworks like Tailwind — not laws. The right breakpoints are
wherever your specific content breaks. Resize your browser slowly and add a breakpoint
wherever the layout looks uncomfortable, not at arbitrary pixel targets.
Every media feature — the full set
width / height
min-width · max-width · exact
Viewport dimensions. The most-used feature. Use range syntax for clarity: (width >= 768px).
orientation
portrait · landscape
Portrait = height > width. Unreliable alone — a tall desktop window is "portrait". Combine with width for meaningful rules.
hover
hover · none
hover: hover = primary pointing device can hover (mouse). hover: none = touch-only. Use to conditionally show hover styles and avoid sticky hover states on mobile.
pointer
fine · coarse · none
fine = mouse (precise). coarse = touchscreen or game controller (imprecise). Use to enlarge tap targets: (pointer: coarse).
any-hover / any-pointer
same as hover / pointer
Considers any connected input device, not just the primary one. A device with both a touchscreen and a Bluetooth mouse has any-hover: hover.
prefers-color-scheme
light · dark
Respects the OS-level dark/light mode setting. Use to provide a dark theme without JavaScript. Combine with CSS custom properties for clean theme switching.
prefers-reduced-motion
reduce · no-preference
User has requested reduced motion in OS accessibility settings. Must be respected — disable or reduce all non-essential animations and transitions.
prefers-contrast
more · less · no-preference · forced
User has increased contrast in OS settings. Use to boost border visibility, text contrast, or switch to a high-contrast palette.
forced-colors
active · none
Windows High Contrast Mode is active. The OS overrides colours. Use to ensure your UI remains functional — borders become critical as backgrounds are replaced.
resolution
dpi · dpcm · dppx
Screen pixel density. min-resolution: 2dppx targets retina/HiDPI screens. Use to serve higher-resolution images — though srcset on img is better for this.
display-mode
browser · standalone · fullscreen · minimal-ui
How the web app is displayed. standalone = installed as PWA. Use to show/hide browser-specific UI elements when running as an installed app.
prefers-reduced-data
reduce · no-preference
User is on a metered/slow connection. Use to skip loading decorative images, web fonts, or video backgrounds. Limited browser support currently.
3. Responsive Units
| Unit | Definition | Best used for |
| % |
Percentage of the parent element's corresponding dimension |
Widths inside a container. Not reliable for heights (parent must have explicit height). |
| vw |
1% of the viewport width |
Full-width sections, fluid font sizes. Causes layout shift on mobile when scrollbar appears — use svw or dvw instead. |
| vh |
1% of the viewport height |
Avoid for mobile layouts — the address bar changes viewport height dynamically, causing content to jump. |
| svh |
1% of the small viewport height (browser chrome visible) |
Safe hero sections that fit when the address bar is shown. Most conservative — always fits. |
| lvh |
1% of the large viewport height (browser chrome hidden) |
Full-screen elements when you want to use maximum space after scrolling hides the address bar. |
| dvh |
1% of the dynamic viewport height (updates as chrome shows/hides) |
Always-accurate full-height sections — but causes reflow as address bar animates. Use deliberately. |
| cqw / cqh |
1% of the query container's width / height |
Inside container queries — size elements relative to their container, not the viewport. The container-equivalent of vw/vh. |
| ch |
Width of the "0" character in the current font |
Prose line length: max-width: 65ch. Scales with font size automatically. |
| rem |
Root element font size (usually 16px) |
Spacing, font sizes. Consistent across the component tree. Respects browser font size preference. |
| em |
Current element's font size |
Padding/margin that should scale with an element's own text size (e.g. button padding). Compounding risk in nested elements. |
clamp() — fluid sizing without breakpoints
clamp(minimum, preferred, maximum) — the value is the preferred expression, clamped between min and max.
320px viewport
clamp(1rem, 4vw, 3rem) → 1rem (minimum locks in)
768px viewport
→ ~1.9rem (4vw = 30.7px ≈ 1.9rem)
1200px viewport
→ 3rem (maximum locks in)
/* clamp() patterns */
/* Fluid heading */
h1 {
font-size: clamp(1.75rem, 4vw + 1rem, 3.5rem);
/* The "4vw + 1rem" preferred value grows with viewport width */
/* Adding 1rem prevents it being tiny on very small screens */
}
/* Fluid spacing */
.section {
padding: clamp(2rem, 5vw, 6rem);
}
/* Fluid container width */
.container {
width: min(100% - 2rem, 1200px);
margin: 0 auto;
/* min() picks the smaller: full width minus padding, or 1200px max */
/* No media query needed — automatically adapts */
}
/* Fluid type scale — all headings scale together */
:root {
--step-0: clamp(1rem, 0.91rem + 0.46vw, 1.25rem);
--step-1: clamp(1.25rem, 1.15rem + 0.54vw, 1.56rem);
--step-2: clamp(1.56rem, 1.41rem + 0.76vw, 1.95rem);
--step-3: clamp(1.95rem, 1.73rem + 1.11vw, 2.44rem);
/* Each step is ~1.25× the previous — a fluid modular scale */
}
4. Container Queries
Container queries let a component respond to the size of its own container
rather than the viewport. This solves the fundamental problem with media queries for
component-based design: a card in a sidebar and the same card in the main content area
are at the same viewport width, but need different layouts.
Media query problem
A card component lives in a 3-column grid. It responds to @media (min-width: 768px).
You move the same card into a narrow sidebar. The viewport is still wide, so the
media query fires — the card renders in its wide layout inside a narrow column.
You must override with more CSS.
Container query solution
The card responds to @container (min-width: 400px). In the 3-column
grid its container is wide — wide layout. In the sidebar its container is narrow —
compact layout. The same component works correctly in both places automatically.
/* Step 1: establish a containment context on the parent */
.card-wrapper {
container-type: inline-size; /* respond to width changes */
container-name: card; /* optional name for targeting */
}
/* Shorthand */
.card-wrapper {
container: card / inline-size;
}
/* Step 2: write a container query inside the component */
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
}
}
/* Without a name, @container matches the nearest container ancestor */
@container (min-width: 400px) {
.card { }
}
/* container-type values: */
/* inline-size — respond to width (most common) */
/* size — respond to both width and height */
/* normal — default, establishes style query context only */
/* Container query units */
@container (min-width: 400px) {
.card__image {
width: 30cqw; /* 30% of the container's width */
}
}
Style queries — the next frontier
/* Style queries: respond to a container's CSS custom property value */
/* Still limited browser support (Chrome 111+) — use as progressive enhancement */
.card-wrapper {
container-type: style;
--variant: featured;
}
@container style(--variant: featured) {
.card {
background: linear-gradient(135deg, #1a2a4a, #0d1117);
border-color: #58a6ff;
}
}
/* Enables data-driven styling without class toggling */
The container cannot query itself. The containment context must be
set on a parent of the element being restyled. If you set
container-type on the card itself and write @container
rules for .card, it won't work — the query must travel up to the nearest
ancestor with containment. Wrap components in a neutral div or use the parent element.
5. Intrinsic Layouts — No Queries Needed
The best responsive CSS often needs no query at all. Several modern CSS features
produce naturally adaptive layouts by responding to available space intrinsically.
Responsive grid (0 queries)
display: grid;
grid-template-columns:
repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
/* wraps automatically at 240px */
Fluid container (0 queries)
width: min(100% - 2rem, 1200px);
margin-inline: auto;
/* full width on small, max
1200px centred on large */
Flex wrapping nav (0 queries)
display: flex;
flex-wrap: wrap;
gap: 8px;
nav a { flex: 1 1 120px; }
/* links wrap at 120px min */
Fluid typography (0 queries)
font-size:
clamp(1rem, 2.5vw + 0.5rem, 1.5rem);
/* grows smoothly with viewport,
min/max clamps prevent extremes */
Responsive sidebar (RAM pattern)
/* Responsive, Asymmetric, Modular */
display: flex;
flex-wrap: wrap;
.sidebar { flex: 1 1 200px; }
.main { flex: 1 1 500px; }
/* stack when combined < 700px */
Aspect ratio boxes
aspect-ratio: 16 / 9;
width: 100%;
/* height is always 56.25%
of width — responsive video */
6. Responsive Images
/* CSS baseline — always set this */
img {
max-width: 100%; /* never overflow container */
height: auto; /* preserve aspect ratio */
display: block; /* remove inline whitespace gap */
}
/* object-fit — control how img fills its box (like background-size) */
.card__image {
width: 100%;
height: 200px;
object-fit: cover; /* fill box, crop to maintain ratio */
object-position: center top; /* anchor to top (for portraits) */
}
/* HTML for art-directed responsive images */
<picture>
<source media="(min-width: 1024px)" srcset="hero-wide.webp">
<source media="(min-width: 640px)" srcset="hero-mid.webp">
<img src="hero-mobile.jpg" alt="Hero image" loading="lazy" decoding="async">
</picture>
/* srcset + sizes for resolution-switching (not art direction) */
<img
src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(min-width: 1024px) 800px, (min-width: 640px) 50vw, 100vw"
alt="Photo">
7. Dark Mode with prefers-color-scheme
/* CSS custom property approach — cleanest architecture */
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-card: #f5f5f5;
--color-border: #e0e0e0;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0d1117;
--color-text: #c9d1d9;
--color-card: #161b22;
--color-border: #30363d;
}
}
/* Components use variables — no per-component dark mode overrides needed */
body { background: var(--color-bg); color: var(--color-text); }
.card { background: var(--color-card); border: 1px solid var(--color-border); }
/* Allow manual override via data attribute (user toggle button) */
[data-theme="dark"] { --color-bg: #0d1117; /* … */ }
[data-theme="light"] { --color-bg: #ffffff; /* … */ }
/* JS sets document.documentElement.dataset.theme = 'dark' */
Respecting reduced motion
/* Always wrap non-essential animations in this query */
@media (prefers-reduced-motion: no-preference) {
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
}
}
/* Alternative: define all animations, then strip in reduced-motion */
.animated {
animation: slide-in 0.4s ease;
}
@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;
}
}
Chapter Summary
| Concept | Key point |
| Viewport meta | width=device-width, initial-scale=1 is required on every page. Never disable user-scalable — it's an accessibility violation. |
| Mobile-first | Write base styles for small screens, use min-width queries to add complexity for larger screens. Simpler base CSS, progressive enhancement. |
| Range syntax | Modern @media (width >= 768px) is cleaner than min-width. Supported in all modern browsers since 2023. |
| hover / pointer | Use (hover: hover) to gate hover effects — prevents sticky hover states on touch devices. Use (pointer: coarse) to enlarge touch targets. |
| svh / lvh / dvh | Prefer svh for safe full-height elements on mobile (avoids address bar jump). dvh is accurate but causes reflow as bar shows/hides. |
| clamp() | clamp(min, preferred, max) for fluid sizing without breakpoints. The preferred value uses vw to grow with viewport. Add a rem offset to prevent tiny sizes. |
| Container queries | container-type: inline-size on the parent, then @container (min-width: X) for the child. The component responds to its container, not the viewport — works correctly in any context. |
| cqw / cqh | Container query units — 1% of the container's width/height. Use inside @container rules to size elements relative to the container. |
| Intrinsic layouts | auto-fit with minmax(), flex-wrap, clamp(), min(), aspect-ratio — these adapt without any query. Prefer them over breakpoints where possible. |
| prefers-reduced-motion | Always wrap non-essential animation in @media (prefers-reduced-motion: no-preference). Or strip all durations to 0.01ms in the reduce branch. |
| Dark mode | Define all colours as CSS custom properties in :root. Override them in @media (prefers-color-scheme: dark). Allow data-theme override for user toggle. |
Exercises
- Mobile-first nav: Build a navigation bar that stacks vertically on mobile (base styles), switches to a horizontal row at 768px, and adds a right-aligned CTA button at 1024px — using only mobile-first min-width media queries.
- Fluid type scale: Create a complete typographic scale (body, h3, h2, h1) using
clamp() for each. The body should range from 1rem to 1.125rem, and each heading level should be approximately 1.25× larger. No breakpoints allowed.
- Container query card: Build a product card that shows image above content in a narrow container (<360px), and image beside content in a wider container. Place the card in both a full-width area and a narrow sidebar — verify both layouts work without changing the card's CSS.
- Dark mode toggle: Implement a complete dark/light mode system using CSS custom properties. The page should respect
prefers-color-scheme by default, but a toggle button sets data-theme on the root element to override the OS preference. Store the choice in localStorage.
- Reduced motion: Add a subtle card hover animation (lift + shadow). Wrap it in
@media (prefers-reduced-motion: no-preference). Test by enabling "Reduce motion" in your OS settings — the animation should disappear entirely, not just slow down.
Next: Chapter 8 — Deep Dive: Transitions and Animations.
The transition shorthand in full, cubic-bezier easing, all animation properties,
keyframe techniques, scroll-driven animations, performance (composited vs
non-composited properties), and accessible motion patterns.