Skip to content
Chapter 21Lesson 6

Breakpoints and the mobile-first reflex

Make Tailwind layouts adapt across screen sizes using mobile-first responsive breakpoints and the media queries they compile to.

In the previous chapter, building the product catalog, you wrote this line and I asked you to take it on faith:

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

The deal at the time was that you’d read md:grid-cols-2 as “more columns once the screen is wide enough,” and we’d come back for the real story. This is where we come back. By the end of this lesson that line won’t be a black box. You’ll know exactly what it compiles to, why it’s written smallest-screen-first, and why this one declaration replaces what used to be three separate blocks of hand-written CSS.

Everything you built in the layout chapter, the flex rows, the grids, the gaps, the positioned elements, you built at one width. You never had to decide which width, because there was only ever one. That’s the gap this lesson closes. A real interface is viewed on a 390px phone held in one hand and on a 1440px monitor, and the same markup has to look deliberate on both. This is the lesson where your layouts learn to respond.

The goal is narrow and concrete. By the end you’ll write responsive layouts mobile-first without thinking about it, you’ll read any md:/lg: chain as a stack of min-width rules, and you’ll know when the right question is “how big is the screen” versus the one we’ll pose only at the very end: “how big is this box.” Two examples will keep coming back. The first is the card grid above, which goes one column, then two, then three. The second is a navigation bar that stacks into a column on a phone and spreads into a row on a desktop. You author each of them once.

Mobile-first: write the small screen, layer up

Section titled “Mobile-first: write the small screen, layer up”

One idea carries this whole lesson, and once you have it the rest is just applying it.

An unprefixed utility applies at every width. A prefixed one, like md: or lg:, applies only from that width up, layering on top of what’s already there. Prefixes never take anything away. They only add.

Walk one element through it. Here is the navigation I promised, stacked on a phone and side by side on a desktop:

<nav className="flex flex-col md:flex-row">

Read it left to right the way the browser does. flex turns on flexbox everywhere. flex-col stacks the children top to bottom at every width, with no exceptions, and that’s the base. Then md:flex-row says: from 768px up, also lay them out in a row. On a phone, the md: rule simply isn’t in play, so the base wins and you get a column. On a desktop, the md: rule activates and overrides the direction to a row. That’s one element with two layouts, where you described the small one first and added the big one.

That word, added, is the part beginners trip on, so let me be clear about where the confusion comes from:

So why author this way? Why describe the phone first and grow up to the desktop, instead of the reverse? The reason has nothing to do with being virtuous about mobile. It’s mechanical, and that’s what makes the habit worth keeping. There are three parts to it.

The first is that you write less. Mobile-first sets the base once and adds rules as the screen grows. The other direction, desktop-first, sets a desktop base and then has to walk it back at every smaller size: undo the three-column grid, undo the row, undo the large padding. You end up shipping the desktop layout plus a pile of overrides whose only job is to dismantle it. Mobile-first ships the small layout plus a few additions, which means fewer rules in the file and fewer rules in your head.

The second is how the cascade behaves. A min-width query, which is what md: is, stacks cleanly in source order: each one activates at a wider breakpoint and lays on top of the last, like sediment. A max-width query, the desktop-first tool, runs the other way and spends its life fighting the base it’s trying to override. One direction composes, the other contends.

The third is simply where people are. For most SaaS surfaces, the majority of sessions are on a phone. When you author mobile-first, the layout you wrote first has the fewest moving parts, is the least likely to break from a forgotten override, and is the one most of your users actually see. The default should be the common case.

None of this makes desktop-first forbidden. It’s a legitimate tool for the case where the small-screen rule really is the exception, where the natural way to describe a thing is “it’s like this on desktop, except cramped down on mobile.” Tailwind gives you max-md: for exactly that, and you’ll reach for it occasionally. But it’s the exception you reach for on purpose, not the direction you start from.

The two tabs below render the identical layout, a stacked-then-row nav, authored both ways. Watch what each one has to say to get there.

<nav className="flex flex-col gap-4 md:flex-row">

Describes the small screen, then adds. The base is the phone layout, a column. md:flex-row adds the row arrangement from 768px up. Nothing is undone, so the wide layout is built on top of the narrow one. This is the direction you start from.

On a single element the two look like a wash, but they aren’t, and the gap only widens as the component grows. Mobile-first is the reflex this whole lesson is building: when you sit down to style something responsive, your fingers should reach for the small-screen version first, every time, without deliberating.

The Tailwind breakpoint scale and the media query underneath

Section titled “The Tailwind breakpoint scale and the media query underneath”

You’ve been writing md: and lg: without me ever telling you what they are. Two things are worth pinning down: what the full set of them is, and what they actually compile to.

The set is small, and you’ll want it in memory, because almost every responsive class you write keys off one of these five names. Each is a breakpoint, the viewport width where a prefixed rule switches on, the same term you met in passing with the auto-fit card grid last chapter:

| Prefix | Activates from | In pixels | | --- | --- | --- | | sm: | 40rem | 640px | | md: | 48rem | 768px | | lg: | 64rem | 1024px | | xl: | 80rem | 1280px | | 2xl: | 96rem | 1536px |

That’s five steps in all. 2xl is the largest one Tailwind ships; there’s no 3xl out of the box. And these are min-width values: md: means “from 768px and wider,” never “at exactly 768px” and never “tablets.”

You met this discipline already in this chapter, with the type scale and the elevation scale: stay on the scale. The same instinct governs breakpoints. You don’t pull a number out of the air and write min-[823px]:flex-row because that’s where this one layout happened to look off. If a project genuinely needs a different point, say the design works better breaking at 800 instead of 768, you change the scale itself, in app/globals.css, rather than scattering one-off pixel values across the codebase:

@theme {
--breakpoint-md: 50rem;
}

That redefines md: to mean 50rem (800px) everywhere, so every md: class in the app moves together and the scale stays coherent. You can also add a step the same way: --breakpoint-xs: 30rem gives you an xs: prefix below sm. The rule of thumb is the same as everywhere else. Reach for a one-off arbitrary value only when the thing genuinely is a one-off, and reach for the scale for everything that isn’t.

The next part is what makes the whole system legible. Every one of those prefixes is shorthand. Underneath, Tailwind compiles md:grid-cols-2 to a plain CSS media query, the same @media rule you’d have hand-written in 2015, applying its styles only when the viewport meets a condition. The following walkthrough shows the desugaring on one class, so the connection between the form you write and the CSS it becomes is concrete.

/* You write this in your JSX: */
md:grid-cols-2
/* Tailwind compiles it to this: */
@media (min-width: 48rem) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

The utility as you write it in a className. md: is the variant; grid-cols-2 is the thing it gates. On its own this is just a string Tailwind recognizes.

/* You write this in your JSX: */
md:grid-cols-2
/* Tailwind compiles it to this: */
@media (min-width: 48rem) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

The md: prefix becomes a @media (min-width: 48rem) wrapper, the 768px breakpoint expressed as the rem value from the scale. The declaration inside only applies when the viewport is at least this wide.

/* You write this in your JSX: */
md:grid-cols-2
/* Tailwind compiles it to this: */
@media (min-width: 48rem) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

Inside the query sits an ordinary grid-template-columns rule. There’s no magic in the output. It’s the CSS you’d have written by hand, generated for you and tucked behind a one-word prefix.

1 / 1

You will rarely, if ever, write a raw @media block yourself, since Tailwind handles that for you. But knowing what it produces is what lets you read someone else’s hand-written CSS, reason about why one rule wins over another, and trust that md: isn’t doing anything exotic. It’s min-width, all the way down.

Two cousins are worth recognizing without dwelling on. The max-* prefixes flip the direction: max-md: compiles to a max-width query and applies below the breakpoint. That’s the desktop-first tool from the last section, a max-width: 48rem under the hood. You can also stack the two to target a single band: md:max-lg:flex applies only from 768px up to 1023px, the slice between md and lg. Ranges like that exist and you’ll see them, but they’re rare in production, since most layouts want “from here up,” not “only in this window.” Recognizing them is enough.

The following diagram lays the five breakpoints out as a ruler so the mobile-first direction is something you can see at a glance. The base, unprefixed, owns the whole line from zero. Each named breakpoint switches on at its width and stays on to the right of it, every rule adding to the ones before. The arrow shows the direction you author in: start at the left edge, add as you move right.

2xl:
xl:
lg:
md:
sm:
base · no prefix
sm: 40rem · 640px
md: 48rem · 768px
lg: 64rem · 1024px
xl: 80rem · 1280px
2xl: 96rem · 1536px
The breakpoint scale as a ruler. Base styles cover the whole width from zero; each breakpoint switches on at its mark and layers rightward. You author left to right — smallest first, adding as the viewport grows.

Breakpoints are where the layout breaks, not where a device sits

Section titled “Breakpoints are where the layout breaks, not where a device sits”

The heading is the sentence to internalize, because the old advice, and a lot of generated boilerplate, gets it wrong.

The outdated way to think about breakpoints is as three device buckets: phone, tablet, desktop. You read md as “tablet,” lg as “desktop,” and you start reasoning about hardware: screen sizes, device categories, what a phone “is.” Drop that frame entirely. It was never quite right, and it ages worse every year, because the space of devices is a continuum now: foldables, tablets in split-screen, a browser window dragged to a third of a monitor, a phone in landscape. There is no clean line where “phone” ends and “tablet” begins.

Here’s the frame that holds up: a breakpoint is the width at which this specific layout stops working. Devices don’t enter into it. You have two product cards sitting comfortably side by side, and as the screen narrows there’s a width where squeezing them gets cramped: the text wraps awkwardly, and the cards get too thin to read. That width is the breakpoint, and you add a rule there to drop to a single column. Whether that width happens to be 600px or 700px or 900px is dictated by the content, by how much room those cards need, not by what category of device sits near that number.

The md at 768px isn’t “the tablet line.” It’s a pragmatic default, a value the Tailwind team picked because it lands reasonably for a lot of common layouts, which is exactly why staying on the scale works most of the time. But the authority is always the content. A dense data table might cramp and need to restructure at 900px, justifying a one-off custom breakpoint. A simple two-up of cards might be fine until 500px. The number follows the layout, never the hardware.

This is how an experienced developer actually finds a breakpoint, and the method is simpler than you might expect: you grab the edge of the browser window and drag it narrower until the layout looks wrong. The width where it breaks is your breakpoint. You set the rule just before that point, and the layout never gets the chance to look bad. You don’t look up “iPhone resolution.” You watch your own layout fail and respond to that.

That dragging motion is something you have to feel before it clicks, so that’s what comes next.

Reading about reflow isn’t the same as watching it happen. A static screenshot freezes the layout at one width, which is exactly what this lesson is about not doing, so the following is a live viewport you can drag.

Grab the handle and pull the frame narrower and wider. Inside is the real card grid (grid-cols-1 md:grid-cols-2 lg:grid-cols-3) and the stacked-then-row nav, styled with genuine Tailwind responsive classes. The readout tells you the current width and which breakpoint is active. Watch for the snaps: at 640px the grid jumps from one column to two, at 1024px it jumps to three, and somewhere around 768px the nav flips from a column to a row. Those snaps are breakpoints firing, each one a min-width rule switching on the instant you cross its width.

500px active: base
grid-cols-1 md:grid-cols-2 lg:grid-cols-3
320px 1440px

Notice that you’re not resizing the page in your browser. You’re resizing a frame inside the page, and the layout inside still responds correctly to its own width. Keep that observation in mind. It seems like a small technical detail right now, but it’s the exact opening the next lesson builds on. For this lesson, the takeaway is the feeling in your hand: a breakpoint is a width you cross, and crossing it activates a new rule. That’s all md: ever was.

The responsive utilities you reach for daily

Section titled “The responsive utilities you reach for daily”

Now for the working vocabulary. The instinct is to memorize a list of responsive utilities, but that’s the wrong shape for this. There’s a pattern underneath, and once you see it the list collapses into two moves:

At a breakpoint, switch the layout primitive. Inside that, scale the visual values. There are two tiers, and almost everything you write is one or the other.

The first tier is layout switches: changing the structural primitive itself at a breakpoint. flex-col md:flex-row (the stacked-on-mobile, row-on-desktop workhorse you’ve already met), grid-cols-1 md:grid-cols-2, block md:flex, md:items-center. These change the shape of the layout.

The second tier is value scaling: keeping the same structure but tuning its numbers as the screen grows. text-base md:text-lg lets type grow on larger screens. p-4 md:p-8 and gap-4 md:gap-8 keep spacing tight on a phone, where every pixel counts, and let it breathe on a desktop. These tune the values within the structure you already chose.

A flat list of six utilities teaches you nothing durable, but one worked example that uses both tiers at once teaches you the move. Here’s the canonical responsive card, stacked and tight on a phone and a roomy row on a desktop. The walkthrough below pulls its className apart in the order you’d build it.

<article className="flex flex-col gap-4 p-4 text-base md:flex-row md:gap-8 md:p-8 md:text-lg">
<img src={product.image} alt="" className="rounded-lg" />
<div>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-muted-foreground">{product.summary}</p>
</div>
</article>

The base, the phone. A vertical stack (flex-col), tight spacing (gap-4, p-4), and body-sized text. Everything here applies at every width. It’s the layout you’d see on the narrowest screen, written first.

<article className="flex flex-col gap-4 p-4 text-base md:flex-row md:gap-8 md:p-8 md:text-lg">
<img src={product.image} alt="" className="rounded-lg" />
<div>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-muted-foreground">{product.summary}</p>
</div>
</article>

The structure switch. From 768px up, the primitive flips from a column to a row, so the image sits beside the text instead of above it. This is tier one: changing the shape at a breakpoint.

<article className="flex flex-col gap-4 p-4 text-base md:flex-row md:gap-8 md:p-8 md:text-lg">
<img src={product.image} alt="" className="rounded-lg" />
<div>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-muted-foreground">{product.summary}</p>
</div>
</article>

The value scaling. Same row structure, looser numbers (a wider gap, more padding, larger text) now that there’s room for them. This is tier two: tuning the values inside the structure you just chose.

<article className="flex flex-col gap-4 p-4 text-base md:flex-row md:gap-8 md:p-8 md:text-lg">
<img src={product.image} alt="" className="rounded-lg" />
<div>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-muted-foreground">{product.summary}</p>
</div>
</article>

All four md: rules together. Read them as one thought: “from 768px up, become a roomy row.” Below that width none of them apply and the base layout stands. One className, two deliberate states.

1 / 1

One more thing these prefixes do: they stack with the state prefixes from the interaction-state lesson. md:hover:bg-accent is perfectly valid, and it means “on hover, but only from 768px up.” You’re composing a viewport condition and an interaction condition on one utility. It reads strangely the first time, but it’s exactly the two ideas stacked. You won’t write it often, so just recognize it when you see it.

Now build one yourself. The exercise below gives you a stacked card on the left as a target and a flat starter on the right. Add the md: utilities, a structure switch and some value scaling, until your card matches the target. Then drag the preview panes narrower and wider to confirm both states are right: it should stack on a narrow width and become a row on a wide one. Matching only the wide state isn’t enough, because mobile-first means the narrow state has to be deliberate too.

The target card is a tight vertical stack on a narrow width and a roomy horizontal row on a wide one. Add md: utilities to your card so it matches: switch to a row layout, widen the gap, and bump the padding from md up. Resize the preview to check both states.

Target
Your output LIVE

The exercise uses literal colors like bg-white and border-slate-200, because the in-browser Tailwind it runs on doesn’t know your project’s custom tokens. In your actual app this card would read the semantic tokens from earlier in the chapter: bg-card, border-border, text-muted-foreground. The responsive utilities, though, are exactly what you’d ship.

Hover, focus, and the rest: the prefers-* and input-device queries

Section titled “Hover, focus, and the rest: the prefers-* and input-device queries”

Everything so far has been one media feature: min-width. But min-width is one axis among several. The same @media machinery answers a whole family of other questions: what color scheme has the user asked for, do they want less motion, can their device even hover. One idea unifies them: the operating system or device sets a signal, and your CSS reads it. That’s the same shape as min-width, and only the axis changes. Once you see them as one family, none of them is a special case.

You’ve already been using some of these without naming the family.

The prefers-* group is the operating-system preferences the user has set, exposed to your CSS:

  • prefers-color-scheme: dark is the OS-level light-or-dark choice. You met it as the engine behind the dark: variant and next-themes a few chapters back. One nuance is worth re-fixing: prefers-color-scheme is the operating system’s preference, while the .dark class is the site’s preference, and next-themes resolves which wins, with the site’s explicit choice beating the OS default. You already wire this up, so just note that it’s a media query like any other.
  • prefers-reduced-motion: reduce is the “I get motion-sick, calm it down” setting, which you met last lesson as the motion-reduce: variant. Same family.
  • prefers-contrast: more (contrast-more:) and forced-colors: active (forced-colors:, the signal for Windows High Contrast Mode ) round out the group. You’ll touch these rarely, but now you can place them: they’re media queries on the same machinery, just feature axes you reach for less.

The takeaway is the connective tissue, not the individual variants, since you already have the reflexes for these from earlier lessons. The point is that the exact mechanism powering md: also powers every one of your accessibility and preference variants. One primitive, many axes.

One axis genuinely needs a few minutes, because it carries a 2026 correction that the older internet still gets wrong: input-device queries, and specifically @media (hover: hover).

This query asks a blunt question: can this device hover at all? A mouse can, since you move the pointer over a thing without clicking. A touchscreen can’t, since there’s no hovering finger, only taps. For years this created a well-known bug. A phone, lacking real hover, would fire :hover on tap and then leave the style stuck until you tapped elsewhere. This was the “sticky hover” bug, where a button you tapped stayed in its hover color.

Here’s the correction, and it’s the inverse of the old advice, so read it carefully: Tailwind v4 already wraps the hover: variant in @media (hover: hover) for you, by default. Your hover:bg-accent only compiles to apply on devices that can actually hover. Two consequences follow, and the second is the one that causes real trouble.

First, the sticky-hover bug is gone for Tailwind’s hover:. On a touchscreen the variant simply doesn’t apply, so there’s nothing to get stuck, and you don’t have to think about it. If you ever hand-write raw :hover in plain CSS, you do not get this gating for free: you’d have to wrap it in @media (hover: hover) yourself. Tailwind users get it automatically, which is one more reason to write the variant rather than the raw selector.

Second, and this is the part that matters, the flip side is now in force: a hover-only affordance is completely invisible on a phone. If the only way to discover that an action exists is to hover, a touch user will never find it, because their device never triggers the hover. Picture a card with an “edit” button that fades in on hover. On a desktop that’s fine: you hover, it appears, you click. On a phone, the button is opacity-0 and stays there forever. The action is unreachable.

So the senior reflex isn’t “watch out for the sticky bug” anymore, since that’s solved. It’s this: design hover as an enhancement on top of something that’s already there, never as the sole way in. The action has to be reachable without hover, and the hover is just polish that adds a little discoverability for mouse users. The example below shows the right shape: an edit button that’s faintly visible by default and intensifies on hover, rather than appearing from nothing.

<button className="opacity-60 transition-opacity hover:opacity-100">
Edit
</button>

The button always sits at opacity-60, present and reachable and tappable on any device, and the hover merely brings it to full strength for users who have a pointer. The wrong version, the one to never ship, is opacity-0 hover:opacity-100: invisible until hovered, which on a phone means invisible forever.

A card has an “edit” button styled opacity-0 hover:opacity-100 — invisible until you hover the card. On a desktop it works fine. What happens to a user on a phone?

The button stays invisible and unreachable — a touchscreen can’t hover, so the hover: rule never fires and the button never leaves opacity-0. The fix is to make the action visible without hover (e.g. opacity-60) and let hover only enhance it.
The button shows up correctly, because Tailwind v4 falls back to firing hover: on tap for touch devices.
The button flickers visible on first tap and then sticks — the classic sticky-hover bug.
Nothing — opacity-0 is overridden by the browser’s default touch styles, so the button is visible by default on phones.

A close relative is pointer: fine versus pointer: coarse, which tells you whether the pointing device is precise (a mouse) or imprecise (a fingertip). The practical use is to avoid hiding a 16-pixel click target behind a coarse pointer, since a finger is blunter than a cursor and needs a bigger target. You won’t reach for it often, but it’s worth knowing it exists.

Two more media features round out the family. You’ll rarely write either, so the goal is just to recognize them when you see them.

@media print, the print: variant, styles the page when it’s being printed. Most SaaS apps never ship a print stylesheet, but the ones with invoices or reports do: print:hidden strips the nav and sidebar off the printout, print:block reveals detail that’s collapsed on screen, and you force black-on-white so it doesn’t waste ink. One paragraph of awareness is enough, and you can reach for it the day you build a printable invoice.

orientation: landscape and orientation: portrait detect which way the device is held. You almost never need them, because min-width captures the same intent more reliably: a landscape phone and a portrait tablet can be the same width, and you usually care about the width, not the rotation. Know the name, but reach for min-width instead.

Here’s a specific and very common move: showing an element at some widths and hiding it at others. Think of a sidebar that only makes sense on a wide screen, or a hamburger menu button that only appears on a narrow one. Two utility patterns cover it, and then there’s a decision that separates them from a different tool, one that reaches back to the hide decision you met in the layout chapter.

The two patterns are mirror images:

  • hidden md:block is hidden on mobile, shown from md up. The base is hidden (display: none), and md:block adds it back as a block from 768px. This is your “desktop-only” element: the sidebar, the secondary panel.
  • md:hidden is shown on mobile, hidden from md up. The base displays normally, and md:hidden removes it from 768px. This is your “mobile-only” element: the hamburger trigger that a desktop, with its always-visible nav, doesn’t need.

Both are doing the same thing, toggling display at a breakpoint. That surfaces a decision worth making deliberately rather than by reflex: toggling display is not the same as the element not existing. Back in the layout chapter, hiding something raised the question “is it even present?”, and the same question governs the choice here, between a CSS breakpoint toggle and a React conditional render.

<aside className="hidden md:block">
<FilterPanel />
</aside>

The node stays in the DOM at every width. CSS only flips display: none on and off. The <FilterPanel> is rendered on the server, sits in the markup, and toggling it costs nothing. This is right for cheap, harmless content where the hidden version doing no work is fine.

The split is exactly the one from the hide decision tree, now keyed to breakpoints. The thing to keep straight is that being in the DOM and being in the accessibility tree are two different memberships. hidden md:block keeps the element mounted in the DOM at every width: the React component, its state, and any data it fetched all stay alive across the breakpoint, just invisible at the narrow end. At the widths where display: none is in effect, the element does drop out of the accessibility tree, the structure a screen reader walks that you met in the layout chapter, so it won’t be announced there. But the component itself is still mounted and working. Conditional rendering tears the whole thing out at once: gone from the DOM, gone from the accessibility tree, state reset. The first is cheaper to toggle and right for simple content. The second is the right call when the hidden thing is expensive to keep mounted, or must reset its state each time it returns, or genuinely shouldn’t exist at all.

Everything in this lesson answered one question: how big is the viewport? That question is the right one a great deal of the time, but it has a blind spot, and finding the edge of it is how we set up the next lesson.

Remember the card from the simulator, the one that responded to the frame’s width rather than the page’s. Stretch that idea into a real layout. You have a <ProductCard> that you’ve made nicely responsive: image beside text when there’s room, stacked when there isn’t. Now you drop it in two places on the same page: the wide main feed and a narrow sidebar. The following figure shows the problem.

viewport: 1280px — one width for everything inside
main feed
row — fits
sidebar
row — overflows
Same viewport — but no room. The card only knows the screen's width, not its own.
One viewport, two contexts. The card sits in a wide feed and a narrow sidebar at the same time — the screen width is identical for both, so a viewport breakpoint can't tell them apart. The card in the sidebar has no way to know it should stack.

Look at what breaks. In the feed, the card has room, so it should be a horizontal row. In the sidebar, it’s pinched, so it should stack vertically. But here’s the catch: both cards are on the same viewport. The screen is one width. A md:flex-row keys off that one screen width, so it gives both cards the same answer. The card in the sidebar gets the wide-screen row layout and overflows its narrow slot, because the only width it was ever told about is the screen’s, and the screen is wide. A viewport query physically cannot tell the card which of its two homes it’s currently in. The information it needs, how much room do I actually have, isn’t the viewport’s width. It’s the card’s own width.

That’s the limit this lesson reaches, and naming exactly where it sits is the whole point of ending here. The decision rule is clean:

  • Page-level structure, like the mobile nav versus the desktop nav, or a one-column versus a two-column page shell, responds to the screen. That’s a viewport query: md:. Everything this lesson taught.
  • Component-level adaptation, like a card that should restructure based on its slot, or a widget that collapses when its panel narrows, responds to the container it sits in. That’s a container query, which is the next lesson.

Most real SaaS interfaces use both at once: the page shell is viewport-driven, and the components living inside it are container-driven. You now have the first half down completely. The card in that sidebar is the second half, a component that needs to respond to its own size rather than the screen’s, and there’s a whole feature, container queries, built for exactly that.

One small thread to tie off before we go. None of this responsive behavior works at all without one line in the document head: <meta name="viewport" content="width=device-width, initial-scale=1">. It tells a mobile browser to use the actual device width as the viewport instead of pretending to be a zoomed-out desktop. Without it, a phone reports itself as ~980px wide and your mobile-first base never even shows. You met it back in the root-layout lesson, and Next.js emits it for you through its metadata API, so you’ll recognize it far more often than you’ll type it. No depth is needed here: just know it’s the one line that makes mobile-first behave.

The next lesson covers how a component responds to its own size instead of the screen’s, and why the answer is one of the nicest features CSS has shipped in years.

Before that, take a moment to consolidate. Sort each scenario by what it should respond to, the screen or the box it lives in. This is the exact split the next lesson builds on, so getting your intuition pointed the right way now pays off immediately.

Sort each scenario by what its layout should respond to. Drag each item into the bucket it belongs to, then press Check.

Viewport query (md:) Responds to the screen's width
Container query (next lesson) Responds to its own container's width
A hamburger button that shows on phones and hides on desktop
A page that’s one column on mobile and two columns on desktop
A top nav that stacks vertically on small screens
A product card that’s a row in the feed but stacks in the sidebar
A stat widget that hides its sparkline when its panel gets narrow
A card whose layout depends on its slot, not the screen