Responsive Design

Tailwind CSS Fundamentals

Chapter 7 · Responsive Design and Container Queries

A page that works at one width is a page that works on one device. Responsive design means the layout adapts: one column on a phone, two on a tablet, four on a desktop. In Tailwind this is the same variant mechanism as chapter 6, with a screen size as the condition: md:grid-cols-2 is "two columns, from the md width upwards". There is one rule to learn (it is mobile-first) and one newer tool to add (container queries, which respond to the size of a parent rather than the screen).

Every example here was run
CSS is copied from Tailwind CSS 4.3.3's output. The breakpoint results come from resizing the viewport of headless Chrome to the exact widths named (using Chrome's device emulation, driven through the DevTools Protocol) and reading back computed styles. Widths are CSS pixels.

Mobile-First: The One Rule

Tailwind's breakpoints are five named widths, defined in the theme and compiled to min-width media queries:

PrefixTheme valuePixelsGenerated media query
sm:--breakpoint-sm: 40rem640px@media (width >= 40rem)
md:48rem768px@media (width >= 48rem)
lg:64rem1024px@media (width >= 64rem)
xl:80rem1280px@media (width >= 80rem)
2xl:96rem1536px@media (width >= 96rem)

A class with no prefix applies at every width. A prefixed class applies at that width and everything wider. So you write the phone layout with plain classes first, and use the prefixes to change it as the screen grows. I put six background colours on one element and resized the viewport:

<div class="bg-red-500 sm:bg-green-500 md:bg-blue-500 lg:bg-purple-500 xl:bg-pink-500 2xl:bg-yellow-500"></div>
viewport width colour 600, 639 red (the unprefixed class) 640, 767 green (sm: from 640) 768, 1023 blue (md: from 768) 1024, 1279 purple (lg: from 1024) 1280, 1535 pink (xl: from 1280) 1536 yellow (2xl: from 1536)

The boundaries are inclusive: at exactly 640px the sm: colour is already in force, at 639px it is not. And each colour holds until the next prefix takes over. This is the idea that trips people up: sm: does not mean "on small screens". It means "from the small breakpoint (640px) up". If you want something on phones only, the plain class is the phone style, and you cancel it above:

<!-- wrong: nothing happens on a phone, because sm: starts at 640px --> <div class="sm:text-center">…</div> <!-- right: centred on phones, left-aligned from 640px up --> <div class="text-center sm:text-left">…</div>
Without the viewport meta tag, phones lie about their width
Every responsive page needs this line in its <head>: <meta name="viewport" content="width=device-width, initial-scale=1">. Without it a mobile browser pretends the screen is about 980px wide and shrinks the result, so none of your small-screen styles apply. I emulated a phone 390px wide and loaded the same page twice. Without the tag: innerWidth was 980, matchMedia('(min-width: 768px)') was true, and the md: colour applied, on a 390px phone. With the tag: innerWidth was 390, the media query was false, and only the plain class applied. If your responsive layout "does nothing" on a real phone, check this first.

Max-Width, Ranges and Arbitrary Breakpoints

Mobile-first covers most needs, but you can also target "below" a width:

VariantMedia queryMeasured
max-md:@media (width < 48rem)applied at 767px, not at 768px (strictly below)
max-sm:, max-lg:, max-xl:, max-2xl:width < 40rem, < 64rem, < 80rem, < 96remthe same pattern
md:max-lg:@media (width >= 48rem) { @media (width < 64rem) { … } }applied from 768 to 1023 only: blue at 768 and 1023, not at 767 or 1024
min-[600px]:@media (width >= 600px)not at 599px, applied at 600px
max-[600px]:@media (width < 600px)(compiled; the same strict rule)

Use max-* and ranges sparingly: they are for the case where a style is needed only in a middle band, such as hiding something between tablet and small-laptop sizes. A one-off width that is not on the scale goes in brackets. Adding your own named breakpoint to the theme (--breakpoint-xs) is chapter 9. The same mechanism also has a few other conditions: landscape: and portrait: (orientation), print: (@media print), and pointer-coarse: and pointer-fine: (@media (pointer: coarse | fine), handy for making touch targets larger on touch screens). Dark mode, reduced motion and contrast are chapter 8.

Designing Upwards from a Phone

The workflow follows from mobile-first. Build the layout at about 360 to 390px wide with plain classes. Widen the window; the moment it looks wrong, add one breakpoint class that fixes that. Three patterns cover most pages, all measured:

A card grid that gains columns:

<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">…eight cards…</div>
viewport columns 400 1 639 1 640 2 1023 2 1024 4 1100 4

A navigation bar that shows its links from md and a menu button below it. The links are hidden md:flex (not displayed, then a flex row from 768px) and the button is md:hidden (shown, then hidden from 768px). Measured: at 767px the links had display: none and the button display: block; at 768px the links were flex and the button none. The open-and-close behaviour of the menu needs a few lines of JavaScript or a <details> element; Tailwind handles only the showing and hiding.

Type and spacing that grow: p-4 text-2xl md:p-8 md:text-4xl measured 16px of padding and 24px text up to 767px, then 32px and 36px from 768px.

Images already behave, thanks to Preflight (chapter 1): img { max-width: 100%; height: auto }, so a large image shrinks to fit a small screen. For a fixed shape use aspect-video w-full object-cover (chapters 4 and 5), and to show different images at different sizes combine hidden md:block with md:hidden. Serving smaller files to smaller screens is HTML's job (srcset and <picture>), not Tailwind's.

Container Queries: Responding to the Parent

Breakpoints ask "how wide is the screen?". That is the wrong question for a reusable component. The same product card might sit in a wide main column and in a narrow sidebar on one page, at one screen width. A container query asks "how wide is the box I am in?". Mark the parent with @container and use the @-prefixed variants on its descendants:

<div class="@container"> <div class="flex flex-col gap-2 @md:flex-row"> <div class="h-24 bg-blue-200 @md:w-40 @md:shrink-0"></div> <div class="flex-1">Text</div> </div> </div>
.\@container { container-type: inline-size; } @container (width >= 28rem) { .\@md\:flex-row { flex-direction: row; } }

@container sets container-type: inline-size, which tells the browser to track the element's width so queries can use it. @md: then applies when the nearest container is at least 28rem (448px) wide. Note that the container sizes are not the screen breakpoints: they come from the container scale, which is smaller:

VariantContainer widthVariantContainer width
@3xs:16rem (256px)@xl:36rem (576px)
@2xs:18rem (288px)@2xl:42rem (672px)
@xs:20rem (320px)@3xl … @7xl:48, 56, 64, 72 and 80rem
@sm:24rem (384px)@max-md:width < 28rem
@md:28rem (448px)@min-[400px]:width >= 400px
@lg:32rem (512px)

Here is the difference from breakpoints, measured. The viewport was 1200px wide in every case. A grid with grid-cols-1 @md:grid-cols-2 was placed inside a container, and beside it a grid with grid-cols-1 md:grid-cols-2 (a screen breakpoint):

Container widthColumns with @md: (container)Columns with md: (screen)
300px12 (the screen is wide, whatever the box)
500px22

The container-query version adapted to its 300px box; the screen-breakpoint version squeezed two columns into it. The same component dropped anywhere on the page lays itself out correctly. Three more details, all measured:

  • The query measures the container's content width, inside its padding. A container w-[460px] p-4 has 428px of content (460 − 32), which is below 448px, so @md: did not apply (1 column). w-[480px] p-4 has exactly 448px of content, and @md: applied (2 columns).
  • Named containers let a nested layout pick which one it listens to: @container/card (container-type: inline-size; container-name: card) and @md/card: (@container card (width >= 28rem)). A 500px named container gave 2 columns.
  • It is not the container class from chapter 4. That one is a centred page wrapper with screen-width limits; @container declares a query container. The names are similar and the two are unrelated.

By the rules of CSS containment (not something I measured), a container with container-type: inline-size cannot take its width from its own contents, so give it a width from its parent or from layout, as the examples above do. As a rule of thumb: use screen breakpoints for the page's overall structure (header, columns, navigation) and container queries for components that appear in more than one place.

Testing Responsive Layouts

In Chrome and Edge, press Ctrl+Shift+M in DevTools to open the device toolbar; Firefox has the same shortcut for its Responsive Design Mode. Drag the width handle slowly from 320px up to 1600px and watch each breakpoint fire (the toolbar can show the media-query bands), then try a phone preset to see the viewport meta tag working. Two checks are worth doing on every page: that nothing scrolls sideways at 320px (a common cause is a fixed width wider than the screen, or the 1fr issue from chapter 4), and that the touch targets are comfortable at phone sizes. An emulator is not a phone: test on a real device before you ship something important.

Hands-On Exercises

Exercise 1

Build a page wrapper (mx-auto max-w-6xl p-4) holding a grid of eight 80px-high cards that has one column on phones, two from 640px and four from 1024px, with 16px gaps. Report the number of columns and the width of a card at viewport widths 390, 700, 1100 and 1500, and explain each card width from the classes.

📄 View solution
Exercise 2

Build one product card that stacks its image above its text in narrow spaces and puts them side by side in wide ones, using a container query. Place the identical card in a 700px main column and a 300px sidebar on the same 1200px-wide page. Show that the two copies lay out differently, and say what would have happened with md: instead of @md:.

📄 View solution
Exercise 3

Read-the-classes audit. For each element below, say what it resolves to at viewport widths 360, 640, 768 and 1024 before you measure, then check with a resized viewport: (a) text-sm md:text-base lg:text-lg (font size), (b) hidden sm:block md:hidden (display), (c) p-2 sm:p-4 max-sm:p-1 (padding), (d) text-red-500 md:max-lg:text-blue-500 (colour). Explain each surprise.

📄 View solution

Chapter 7 Quick Reference

  • Always <meta name="viewport" content="width=device-width, initial-scale=1">; without it a 390px phone reported innerWidth 980 and applied md:
  • Breakpoints: sm 640, md 768, lg 1024, xl 1280, 2xl 1536 (width >= …, inclusive). Unprefixed = all sizes; prefixed = that size and up. sm: is not "on phones"
  • Write the phone layout first, add sm: md: lg: only where it breaks: text-center sm:text-left, grid-cols-1 sm:grid-cols-2 lg:grid-cols-4, hidden md:flex + md:hidden
  • max-md: (width < 48rem, strict), range md:max-lg: (768 to 1023), arbitrary min-[600px]: / max-[600px]:
  • Other conditions: landscape: portrait: print: pointer-coarse: pointer-fine:
  • Container query: parent @container (container-type: inline-size), children @md: etc. (@3xs 16rem … @md 28rem … @7xl 80rem), @max-md:, @min-[400px]:
  • The query measures the container's CONTENT width (inside its padding); name containers with @container/card and @md/card:
  • Screen breakpoints for page structure, container queries for reusable components; DevTools device toolbar is Ctrl+Shift+M; check 320px for sideways scrolling
Coming next
Tailwind CSS Fundamentals 8 covers the user-preference variants: dark: and how to switch dark mode by a class or attribute, then motion-safe: and motion-reduce:, contrast-more:, forced-colors: and print:.