Breakpoints and mobile-first layouts
Make Tailwind layouts adapt across screen sizes with mobile-first breakpoints and the media queries they compile to.
In the previous chapter 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">You read md:grid-cols-2 as “more columns once the screen is wide enough.” This lesson is where we come back for the real story: what that line compiles to, why it’s written smallest-screen-first, and why one declaration replaces three hand-written blocks of CSS.
Everything you built in the layout chapter, you built at one width. But a real interface is viewed on a 390px phone and on a 1440px monitor, and the same markup has to look deliberate on both. This is where your layouts learn to respond.
You’ll write layouts mobile-first, read any md:/lg: chain as a stack of min-width rules, and learn to ask “how big is the screen” (and, only at the very end, “how big is this box”). Two examples recur: the card grid above, which goes one column, then two, then three; and a navigation bar that stacks into a column on a phone and spreads into a row on a desktop. You author each once.
Mobile-first: write the small screen, layer up
Section titled “Mobile-first: write the small screen, layer up”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 a nav that stacks on a phone and sits 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 is the base: it stacks the children top to bottom at every width. Then md:flex-row adds a row layout from 768px up. On a phone the md: rule isn’t in play, so the base wins and you get a column; on a desktop it activates and overrides the direction to a row. One element, two layouts, with the small one described first and the big one added on top.
That word, added, is where beginners trip:
So why describe the phone first and grow up to the desktop? The reason is mechanical, and that’s what makes the habit worth keeping. Three parts to it.
You write less. Mobile-first sets the base once and adds rules as the screen grows. Desktop-first sets a desktop base and then walks it back at every smaller size: undo the three-column grid, undo the row, undo the large padding. You ship the desktop layout plus a pile of overrides whose only job is to dismantle it, instead of a small layout plus a few additions.
The cascade composes. 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 runs the other way and spends its life fighting the base it’s overriding. One direction composes, the other contends.
It matches the common case. For most web app surfaces, most sessions are on a phone. Author mobile-first and 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 users actually see.
Desktop-first isn’t forbidden. It fits the case where the small screen really is the exception, where the natural description is “it’s like this on desktop, except cramped down on mobile.” Tailwind gives you max-md: for exactly that. It’s the exception you reach for on purpose, not the direction you start from.
The two tabs below render the identical 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 column. md:flex-row adds the row 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.
<nav className="flex flex-row gap-4 max-md:flex-col">Describes the big screen, then walks it back. The base is the desktop row, and max-md:flex-col overrides it to a column below 768px. The same pixels end up on screen, but you shipped a rule whose only job is to undo the base. Across a real component, those overrides pile up.
On a single element the two look like a wash, but the gap widens as the component grows. Mobile-first is the reflex this lesson is building: when you style something responsive, your fingers reach for the small-screen version first, every time.
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 my ever saying what they are. Two things are worth pinning down: the full set of them, and what they compile to.
The set is small, and worth memorizing, because nearly every responsive class keys off one of these five names. Each is a breakpoint: the viewport width where a prefixed rule switches on.
| Prefix | Activates from | In pixels |
|---|---|---|
sm: | 40rem | 640px |
md: | 48rem | 768px |
lg: | 64rem | 1024px |
xl: | 80rem | 1280px |
2xl: | 96rem | 1536px |
2xl is the largest Tailwind ships; there’s no 3xl out of the box. All five are min-width values: md: means “from 768px and wider,” never “at exactly 768px” and never “tablets.”
The discipline is the one you met with the type scale and the elevation scale: stay on the scale. Don’t pull a number from the air and write min-[823px]:flex-row because one layout happened to look off there. If a project genuinely needs a different point, say it breaks better at 800 than 768, 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: as 50rem (800px) everywhere, so every md: class 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. Reach for an arbitrary value only when the thing really is a one-off; reach for the scale for everything else.
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, applied only when the viewport meets a condition. Here is the desugaring on one class, so the link between what 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 what 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 as the rem value from the scale. The rule inside applies only 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.
You’ll rarely write a raw @media block yourself. But knowing what Tailwind 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 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, the desktop-first tool from the last section, max-width: 48rem under the hood. You can also stack the two to target one band: md:max-lg:flex applies only from 768px to 1023px, the slice between md and lg. Such ranges are rare in production, since most layouts want “from here up,” not “only in this window.” Recognizing them is enough.
Breakpoints mark where the layout breaks, not where a device sits
Section titled “Breakpoints mark where the layout breaks, not where a device sits”The old advice, repeated across a lot of generated boilerplate, treats breakpoints as three device buckets: phone, tablet, desktop. You read md as “tablet,” lg as “desktop,” and start reasoning about hardware. Drop that frame. Devices are a continuum now, foldables, split-screen tablets, a window dragged to a third of a monitor, and 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. Two product cards sit comfortably side by side, and as the screen narrows there’s a width where they get cramped: the text wraps awkwardly, 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 it lands at 600px or 900px is dictated by the content, by how much room those cards need, not by what device sits near that number.
So md at 768px isn’t “the tablet line.” It’s a pragmatic default the Tailwind team picked because it lands well for many common layouts, which is why staying on the scale works most of the time. But the content is always the authority. A dense data table might 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 finds a breakpoint: grab the edge of the browser window and drag it narrower until the layout looks wrong. The width where it breaks is your breakpoint, and you set the rule just before that point so 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.
Dragging the viewport
Section titled “Dragging the viewport”A static screenshot freezes the layout at one width, which is exactly what responsive design avoids, so here is a live viewport you can drag instead.
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, with genuine Tailwind responsive classes; the readout shows the current width and active breakpoint. Watch for the snaps: at 640px the grid jumps from one column to two, at 1024px to three, and around 768px the nav flips from a column to a row. Each snap is a min-width rule switching on the instant you cross its width.
grid-cols-1
md:grid-cols-2 lg:grid-cols-3 Notice that you aren’t resizing the page in your browser, you’re resizing a frame inside the page, and the layout still responds to its own width. Hold onto that observation; the next lesson builds on it. For now, the takeaway is the feeling in your hand: a breakpoint is a width you cross, and crossing it activates a new rule.
The two-tier responsive pattern
Section titled “The two-tier responsive pattern”You don’t memorize a list of responsive utilities; you learn one pattern with 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. These change the shape of the layout.
The second tier is value scaling: keeping that structure but tuning its numbers as the screen grows. text-base md:text-lg grows the type; p-4 md:p-8 and gap-4 md:gap-8 keep spacing tight on a phone and let it breathe on a desktop. These tune the values within the structure you already chose.
One worked example uses both tiers at once. Here’s the canonical responsive card: stacked and tight on a phone, a roomy row on a desktop. The walkthrough 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. With no prefix, these apply at every width. This is the narrowest layout, 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. 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 (wider gap, more padding, larger text) now that there’s room. 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 as one thought: “from 768px up, become a roomy row.” Below that width none apply and the base layout stands. One className, two deliberate states.
Responsive prefixes also stack with the state prefixes from the interaction-state lesson. md:hover:bg-accent is valid: it means “on hover, but only from 768px up,” composing a viewport condition and an interaction condition on one utility. You won’t write it often, so just recognize it when you see it.
Now build one yourself. The exercise gives you a stacked card on the left as a target and a flat starter on the right. Add 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: 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.
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. Your real app would use the semantic tokens from earlier in the chapter: bg-card, border-border, text-muted-foreground. The responsive utilities are exactly what you’d ship.
Beyond min-width: preference and input-device queries
Section titled “Beyond min-width: preference and input-device queries”Every variant so far has used one media feature, min-width. But that’s one axis among several. The same @media machinery answers a whole family of questions, and the shape never changes: the operating system or device sets a signal, and your CSS reads it. Only the axis differs.
You’ve already used several of these. The prefers-* group exposes operating-system preferences to your CSS: prefers-color-scheme drives the dark: variant, prefers-reduced-motion drives motion-reduce:, and prefers-contrast (contrast-more:) and forced-colors (forced-colors:, the signal for Windows High Contrast Mode ) round out the set. The point isn’t any single variant, it’s the connective tissue: the exact mechanism powering md: also powers every accessibility and preference variant. One primitive, many axes.
One axis needs real attention, because it carries a 2026 correction the older internet still gets wrong: input-device queries, specifically @media (hover: hover). This query asks whether the device can hover at all. A mouse can; a touchscreen can’t, since there’s no hovering finger, only taps. For years this caused the “sticky hover” bug: a phone would fire :hover on tap and leave the style stuck until you tapped elsewhere, so a button you tapped stayed in its hover color.
Here’s the correction, and it inverts the old advice: Tailwind v4 wraps the hover: variant in @media (hover: hover) for you by default. Your hover:bg-accent only applies on devices that can actually hover. Two consequences follow.
First, the sticky-hover bug is gone for Tailwind’s hover:. On a touchscreen the variant simply doesn’t apply, so nothing gets stuck. Raw hand-written :hover in plain CSS does not get this gating free; you’d have to wrap it yourself. One more reason to write the variant, not the raw selector.
Second, and this is the part that bites: a hover-only affordance is invisible on a phone. If the only way to discover an action is to hover, a touch user never finds it, because their device never triggers the hover. Picture a card with an “edit” button that fades in on hover. On desktop that’s fine. On a phone the button sits at opacity-0 forever, and the action is unreachable.
So the senior reflex isn’t “watch out for the sticky bug” anymore, that’s solved. It’s this: design hover as an enhancement on top of something already there, never as the sole way in. The action must be reachable without hover; the hover is just polish that adds discoverability for mouse users. The right shape is a button faintly visible by default that intensifies on hover:
<button className="opacity-60 transition-opacity hover:opacity-100"> Edit</button>The button always sits at opacity-60, reachable and tappable on any device, and hover merely brings it to full strength for pointer users. The version 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?
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.hover: on tap for touch devices.opacity-0 is overridden by the browser’s default touch styles, so the button is visible by default on phones.hover: in @media (hover: hover), so on a touch device the hover rule doesn’t apply at all — the button stays at opacity-0 with no way for a tap to reveal it. That’s why a hover-only affordance is a real bug, not a cosmetic one: the action simply can’t be discovered or used. The sticky-hover answer describes the old pre-v4 behavior, which the gating specifically eliminated — the variant no longer fires on tap, so it can’t stick. And there’s no magic “fall back to tap” or browser override; opacity-0 is opacity-0. The reflex: any affordance must be reachable without hover, with hover as enhancement only.Three more features round out the family, all recognize-only. pointer: fine versus pointer: coarse tells you whether the pointing device is precise (a mouse) or blunt (a fingertip), so you can give coarse pointers a bigger target. @media print (the print: variant) styles the page for printing, which matters only for the rare invoice or report. And orientation: landscape/portrait detects how the device is held, but reach for min-width instead, since it captures the same intent more reliably.
Showing and hiding by breakpoint
Section titled “Showing and hiding by breakpoint”A common move is showing an element at some widths and hiding it at others: a sidebar that only makes sense on a wide screen, or a hamburger button that only appears on a narrow one. Two utility patterns cover it, and choosing between them revives the hide decision from the layout chapter.
The two patterns are mirror images:
hidden md:blockis hidden on mobile, shown frommdup. The base ishidden(display: none), andmd:blockbrings it back as a block from 768px. This is your “desktop-only” element: the sidebar, the secondary panel.md:hiddenis shown on mobile, hidden frommdup. The base displays normally, andmd:hiddenremoves it from 768px. This is your “mobile-only” element: the hamburger trigger a desktop’s always-visible nav doesn’t need.
Both toggle display at a breakpoint, which raises a decision worth making deliberately: toggling display is not the same as the element not existing. The same question from the layout chapter, “is it even present?”, now governs the choice 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> renders on the server, sits in the markup, and toggling it costs nothing. Right for cheap, harmless content where the hidden version doing no work is fine.
{isDesktop && ( <aside> <FilterPanel /> </aside>)}The node leaves the DOM entirely when hidden. A useMediaQuery-style hook drives isDesktop, and when it’s false the <FilterPanel> never mounts: gone from the markup, gone from the accessibility tree, its state torn down. Right when the off-screen content shouldn’t exist for assistive tech, is expensive to render, or must reset its state.
The catch is that being in the DOM and being in the accessibility tree are separate memberships. hidden md:block keeps the element mounted at every width, so its React component, state, and fetched data stay alive across the breakpoint, just invisible at the narrow end. Where display: none is in effect it does drop out of the accessibility tree, so a screen reader won’t announce it, but the component keeps running. A conditional render tears the whole thing out at once: gone from the DOM, gone from the accessibility tree, state reset. Use the display toggle for cheap content; use the conditional render when the hidden thing is expensive to keep mounted, must reset its state, or shouldn’t exist at all.
When the viewport is the wrong question
Section titled “When the viewport is the wrong question”Every breakpoint in this lesson asks the same thing: how wide is the viewport? That question is usually the right one, but it has a blind spot worth finding.
Picture a <ProductCard> you’ve made responsive: image beside text when there’s room, stacked when there isn’t. You drop it in two places on the same page, the wide main feed and a narrow sidebar.
In the feed the card has room, so it should be a horizontal row; in the sidebar it’s pinched, so it should stack. But both cards are on the same viewport, one screen width. A md:flex-row keys off that one width and hands both cards the same layout, so the sidebar copy gets the wide-screen row and overflows its slot. A viewport query cannot tell the card which home it’s in. The information it needs, how much room do I actually have, is the card’s own width, not the screen’s.
That gap marks a clean decision rule:
- Page-level structure, like mobile nav versus desktop nav or a one- versus two-column shell, responds to the screen: a viewport query,
md:. - Component-level adaptation, like a card that restructures based on its slot, responds to the container it sits in: a container query, the next lesson.
Most real web interfaces use both at once: a viewport-driven page shell holding container-driven components.
One thread to tie off first. None of this responsive behavior works 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 shows. Next.js emits it for you through its metadata API, so you’ll recognize it far more often than you’ll type it.
The next lesson covers container queries: how a component responds to its own size instead of the screen’s.
External resources
Section titled “External resources”MDN's reference on the primitive under every responsive prefix — min-width, the prefers-* features, and the input-device queries, written as plain CSS.
The Tailwind docs page for the form you write — the breakpoint scale, the mobile-first model, and how to customize or add breakpoints in @theme.
Google's interactive Learn Responsive Design course — the same min-width model this lesson teaches, plus the device and preference features, with live demos.
A deep dive on the hover and pointer axes from this lesson — hover: hover, pointer: coarse/fine, and the any-* variants for multi-input devices.