Session 8 — Responsive Design: One Site, Every Screen
We'll go from the basics of media query syntax all the way to container queries and building a fully responsive layout — no frameworks, pure CSS.
The @media rule, media features, logical operators, and the new range syntax.
Standard breakpoints, the mobile-first philosophy, and why min-width beats max-width.
clamp() for font sizes that scale without media queries, aspect-ratio, object-fit, and viewport units.
Build a complete page layout from mobile base styles up to a desktop 3-column grid, step by step.
The modern evolution: @container lets components respond to their parent — not the viewport.
A media query is a condition written in CSS. When the condition is true, the rules inside apply. When it's false, they don't. The browser re-evaluates them every time the viewport changes.
/* @media [type] and (feature) { rules } */ @media screen and (min-width: 768px) { .container { max-width: 1200px; } } /* └──┬──┘ └──┬──┘ └──────────────┘ │ │ │ at-rule media type media feature */
Media types narrow down which output device the rules apply to:
| Type | When it applies | Use case |
|---|---|---|
all |
Always (default when omitted) | General rules |
screen |
Any screen device | Web layouts, animations |
print |
Printing / print preview | Hide navbars, adjust fonts for paper |
Media features are the conditions you test. The most common:
/* ── Viewport dimensions ── */ @media (min-width: 768px) { } /* viewport ≥ 768px */ @media (max-width: 767px) { } /* viewport < 768px */ @media (min-height: 500px) { } /* tall viewports */ /* ── Device orientation ── */ @media (orientation: portrait) { } /* taller than wide */ @media (orientation: landscape) { } /* wider than tall */ /* ── User preferences ── */ @media (prefers-color-scheme: dark) { } /* user's OS is dark mode */ @media (prefers-reduced-motion: reduce) { } /* user wants less animation */ @media (hover: hover) { } /* device has a real hover (mouse) */ /* ── Modern range syntax (cleaner) ── */ @media (width >= 768px) { } /* same as min-width: 768px */ @media (600px <= width <= 1024px) { } /* range: tablet only */ /* ── Logical operators ── */ @media screen and (min-width: 768px) { } /* AND — both must be true */ @media (max-width: 600px), (orientation: portrait) { } /* OR — comma = "or" */ @media not (prefers-color-scheme: dark) { } /* NOT */
Writing @media (min-width: 768px) without screen is perfectly valid and extremely common. The default is all, which covers screens, print, and everything else.
Some users have vestibular disorders — spinning and sliding animations can cause physical discomfort. Wrapping all your animations in @media (prefers-reduced-motion: no-preference) (or disabling them inside a reduce block) is a simple, high-impact accessibility win.
Click a device size below to see how the same HTML reflows. The live CSS panel shows which @media block is active at each size. This is exactly what the browser does as you resize the window.
The key insight: the styles outside any @media block always apply. You write the mobile styles first (no query needed), then override with min-width queries for larger screens.
Here are the most widely used breakpoints — you'll see these everywhere:
Don't add breakpoints at every device width. Add them only where your layout breaks. Resize your browser slowly and add a breakpoint exactly where things start to look bad. Two or three breakpoints handle 90% of real-world layouts.
There are two ways to use media queries. They produce the same visual result but have very different implications for code quality and performance.
min-width to add complexitymax-width to undo complexity/* ── Base: mobile styles — NO query needed ── */ .cards { display: grid; grid-template-columns: 1fr; /* single column */ gap: 1rem; } .nav-links { display: none; } /* hidden on mobile */ .hamburger { display: flex; } /* shown on mobile */ /* ── Tablet: add complexity with min-width ── */ @media (min-width: 600px) { .cards { grid-template-columns: 1fr 1fr; } .nav-links { display: flex; } .hamburger { display: none; } } /* ── Desktop: more complexity ── */ @media (min-width: 1024px) { .cards { grid-template-columns: repeat(3, 1fr); } .container { max-width: 1200px; margin: 0 auto; } }
/* ── Base: desktop styles ── */ .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .nav-links { display: flex; } .hamburger { display: none; } /* ── Tablet: undo desktop rules ── */ @media (max-width: 1023px) { .cards { grid-template-columns: 1fr 1fr; } } /* ── Mobile: undo even more ── */ @media (max-width: 599px) { .cards { grid-template-columns: 1fr; } /* overriding again */ .nav-links { display: none; } /* overriding again */ .hamburger { display: flex; } /* overriding again */ } /* Mobile had to download the desktop rules only to throw them away */
Start simple. Add complexity. Mobile-first means your CSS reads as a progressive enhancement — from minimal to full-featured. This is also why min-width queries stack cleanly: each breakpoint only adds to what came before, never fights it.
Fixed pixel font sizes look wrong on small or very large screens. Media queries help, but they create abrupt jumps. clamp() gives you smooth, fluid scaling with a guaranteed minimum and maximum.
font-size: 3rem is 48px on every device — way too big on a 320px phone, maybe too small on a 2560px monitor.
font-size: 5vw is fluid but has no floor or ceiling. On a 200px screen it's 10px (unreadable). On a 3000px monitor it's 150px (absurd).
Returns the preferred value, but never goes below min or above max. The preferred value is usually a vw expression so it scales fluidly between the two bounds.
/* clamp(minimum, preferred, maximum) */ .hero-title { /* Never smaller than 1.5rem (24px), never larger than 4rem (64px). Between those: scales with viewport at 5vw per 100px of width. */ font-size: clamp(1.5rem, 5vw, 4rem); } .body-text { font-size: clamp(0.875rem, 2vw, 1.125rem); /* 14px → fluid → 18px */ } .card-title { font-size: clamp(1rem, 3vw, 1.75rem); /* 16px → fluid → 28px */ } /* You can also mix in calc() for finer control */ .section-heading { font-size: clamp(1.5rem, 1rem + 3vw, 3rem); /* The "1rem + 3vw" ensures the font never drops below 1rem even at tiny viewports, because 1rem is always added */ } /* clamp() works for spacing too, not just fonts */ .section { padding: clamp(2rem, 8vw, 6rem); /* fluid vertical padding */ max-width: clamp(300px, 90vw, 1200px); /* responsive container width */ }
Drag the slider to simulate different viewport widths. The font size is computed from clamp(1rem, 4vw, 3rem) — watch the bounds clamp the value.
min(4vw, 2rem) returns whichever is smaller — a cap with no floor. max(1rem, 4vw) returns whichever is larger — a floor with no ceiling. clamp() is just shorthand for max(min-value, min(preferred, max-value)).
Images have an intrinsic size. Without CSS, a 1200px-wide image simply overflows a 375px mobile screen. These properties fix that.
/* ── Rule #1: images never wider than their container ── */ img { max-width: 100%; /* never overflow */ height: auto; /* keep aspect ratio */ display: block; /* remove inline-block gap under image */ } /* ── aspect-ratio: lock proportions without hardcoding height ── */ .card-image { width: 100%; aspect-ratio: 16 / 9; /* always 16:9, no matter the container width */ object-fit: cover; /* fill the box, crop edges */ } .avatar { width: 3rem; aspect-ratio: 1; /* perfect square (shorthand for 1/1) */ border-radius: 50%; object-fit: cover; } /* ── object-fit controls image cropping ── */ .img-cover { object-fit: cover; } /* fill box, crop — most common */ .img-contain { object-fit: contain; } /* fit inside box, show all */ .img-fill { object-fit: fill; } /* stretch to fill — distorts! */
/* ── Classic viewport units ── */ 1vw /* 1% of viewport width */ 1vh /* 1% of viewport height */ 1vmin /* 1% of the smaller of vw or vh */ 1vmax /* 1% of the larger of vw or vh */ .hero { min-height: 100vh; } /* full-screen hero */ .sidebar{ width: 25vw; } /* quarter of viewport width */ /* ── Problem with 100vh on mobile ── On mobile browsers, the address bar eats into 100vh. The page is actually shorter than 100vh. */ /* ── Modern fix: dynamic viewport units ── */ .hero { min-height: 100dvh; } /* dvh = dynamic viewport height — adjusts for the address bar */ .hero { min-height: 100svh; } /* svh = small viewport height — uses the smallest possible value */ .hero { min-height: 100lvh; } /* lvh = large viewport height — ignores the address bar */ /* ── srcset for resolution switching ── */ /* In HTML, not CSS — loads the right-sized image per device */
<!-- srcset: browser picks the right size for the screen --> <img src="hero-800.jpg" srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w" sizes="(min-width: 768px) 50vw, 100vw" alt="Hero image" > <!-- picture: art direction — different crops for different sizes --> <picture> <source media="(min-width: 768px)" srcset="hero-wide.jpg"> <source media="(min-width: 480px)" srcset="hero-square.jpg"> <img src="hero-portrait.jpg" alt="Hero"> <!-- fallback --> </picture>
Replace 100vh with 100dvh for hero sections and modals. It correctly accounts for the mobile browser address bar, which collapses and expands as the user scrolls — solving the classic "hero is too tall on mobile" bug.
Let's build a complete, responsive card page from scratch — mobile first. The HTML never changes. Only CSS changes at each breakpoint.
HTML is layout-agnostic. Write it in logical reading order — this is also the mobile order. No layout classes. No inline styles.
<header class="site-header"> <a href="/" class="brand">Portfolio</a> <button class="hamburger" aria-label="Open menu">☰</button> <nav class="main-nav"> <a href="#work">Work</a> <a href="#about">About</a> <a href="#contact">Contact</a> </nav> </header> <section class="hero"> <h1>Creative <span>Portfolio</span></h1> <p>Design & development for the modern web.</p> </section> <main class="cards" id="work"> <article class="card"> <img src="project1.jpg" alt="Project One"> <h2>Project One</h2> <p>E-commerce redesign, 2024.</p> </article> <!-- more cards... --> </main>
Style for the smallest screen first. Single column, large touch targets, hamburger visible. This is the CSS that always loads.
.site-header { display: flex; align-items: center; padding: 1rem; background: #111; gap: 1rem; } .main-nav { display: none; } /* hidden on mobile */ .hamburger { display: block; } /* shown on mobile */ .hero { padding: 2rem 1rem; text-align: center; } .hero h1 { font-size: clamp(2rem, 8vw, 4rem); /* fluid title */ } .cards { display: grid; grid-template-columns: 1fr; /* single column */ gap: 1.5rem; padding: 1rem; } .card img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; }
@media (min-width: 600px) { .main-nav { display: flex; gap: 1.5rem; margin-left: auto; } .hamburger { display: none; } .cards { grid-template-columns: 1fr 1fr; /* 2 columns */ padding: 1.5rem; } .hero { padding: 3rem 1.5rem; } }
@media (min-width: 1024px) { .site-header, .hero, .cards { max-width: 1200px; /* stop growing beyond 1200px */ margin-inline: auto; /* center on wide screens */ } .cards { grid-template-columns: repeat(3, 1fr); /* 3 columns */ padding: 2rem; } .hero { padding: 5rem 2rem; text-align: left; /* left-aligned on large screens */ } }
/* Animations for users who are OK with them */ .card { transition: transform 0.3s, box-shadow 0.3s; } .card:hover { transform: translateY(-4px); } /* Kill animations for users who prefer less motion */ @media (prefers-reduced-motion: reduce) { .card { transition: none; } .card:hover { transform: none; } /* Also reset any CSS animations globally */ *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
Here is everything above combined. Toggle between sizes to see exactly how the CSS breakpoints transform the layout.
Write the minimum viable layout for mobile with no queries. Then add @media (min-width: X) blocks to progressively enhance the layout for wider screens. Three sizes, two queries, done.
Media queries look at the viewport. But what if you have a reusable card component that lives in a narrow sidebar on one page and a wide main section on another? The viewport is the same — but the available space is very different.
@media (min-width: 768px) would activate for both — even though the sidebar card is too narrow for a horizontal layout.Container queries solve this. Instead of watching the viewport, the component watches its own containing element.
/* Step 1: Tell the parent it's a container */ .card-wrapper { container-type: inline-size; /* watch the inline (horizontal) size */ container-name: card; /* optional name for specificity */ } /* Step 2: Style the child based on the CONTAINER width — not viewport */ .card { display: flex; flex-direction: column; /* stacked by default (narrow container) */ } @container card (min-width: 480px) { /* Only fires when the .card-wrapper is at least 480px wide */ .card { flex-direction: row; } /* side-by-side */ .card-image { width: 200px; flex-shrink: 0; } } /* The same component now adapts perfectly in sidebar AND main content */
Resize the container below. The card switches from vertical to horizontal layout at 480px of container width — not viewport width.
container-type: inline-size — watches the horizontal size (most common). container-type: size — watches both width and height (rare, has performance implications). Never use a container as a flex or grid item directly — wrap it in an extra div if needed.
| Feature | @media | @container |
|---|---|---|
| Responds to | Viewport size | Parent container size |
| Best for | Page layout, global breakpoints | Reusable UI components |
| Use case | Nav → hamburger, 1 col → 3 cols | Card stack → horizontal, sidebar widget |
| Browser support | Excellent (all browsers) | Good (all modern browsers since 2023) |
| Required setup | None | Parent needs container-type |
Let's review the key ideas from this session, then put them to the test.
Write the smallest layout first. Use min-width queries to layer on complexity for wider screens.
Combine media type + features with logical operators. Most queries only need (min-width: Xpx).
Fluid typography and spacing without media queries. The preferred value uses vw to scale smoothly.
Components that respond to their container's width — not the viewport. Perfect for reusable UI.
font-size: clamp(1rem, 5vw, 3rem) return on a 240px-wide viewport?You now have the full CSS layout toolkit: Flexbox, Grid, and Responsive Design. In the next session we'll tackle state management — prop drilling, useContext, useReducer, and building a global store with zero external libraries.