CSS
Tailwind CSS
UI/UX
Responsive Design
Frontend
Web Development
Mobile-First

Tailwind CSS Breakpoints: Responsive Design in v4

Tailwind CSS v4 breakpoints: the rem scale, max-* ranges, custom @theme breakpoints, using them in media queries, and when a container query fits better.

Updated
8 min read
Chamikara Nayanajith

Tailwind ships five breakpoints: sm at 40rem (640px), md at 48rem (768px), lg at 64rem (1024px), xl at 80rem (1280px) and 2xl at 96rem (1536px). Every one is a minimum width, so md:flex means 768px and up rather than 768px only. In v4 you change them with --breakpoint-* variables inside @theme, not with a screens key in tailwind.config.js.

That last sentence is where most v3 habits break. This post covers the scale and the cascade that trips people up, the max-* variants for the cases that genuinely need them, adding a breakpoint in v4, and when a container query is the better tool. The compiled CSS shown throughout came out of Tailwind 4.3.3 rather than from the docs, because a couple of the values are not what you would guess.

How do Tailwind breakpoints actually work?

An unprefixed utility applies at every width. A prefixed one applies from its breakpoint upward and overrides the unprefixed value. So p-4 md:p-8 is not two alternatives, it is a base value plus an override that starts at 768px.

html
<div class="p-4 md:p-8">
  <!-- 1rem of padding everywhere, 2rem from 48rem up -->
</div>

Compiling that file with Tailwind 4.3.3 produces this, and it is worth reading once because it explains the rest of the article:

css
.p-4 {
  padding: calc(var(--spacing) * 4);
}
@media (width >= 48rem) {
  .md\:p-8 {
    padding: calc(var(--spacing) * 8);
  }
}

Two things to notice. The media query is a range query, width >= 48rem, and it wraps the class: below 48rem the browser never sees a md:p-8 rule at all. Releases before 4.3.3 nested the query inside the class rule instead, which behaves identically, so older output you find online is the same thing in a different shape. That is also the development output. A production build runs it through Lightning CSS, which rewrites the range to min-width: 48rem for older browsers; the breakpoint is the same. Spacing comes from a single --spacing variable through calc(), which is why v4 can generate p-13 or p-27 without any configuration.

The default scale

PrefixrempxEmitted media query
(none)--none, applies everywhere
sm40rem640pxwidth >= 40rem
md48rem768pxwidth >= 48rem
lg64rem1024pxwidth >= 64rem
xl80rem1280pxwidth >= 80rem
2xl96rem1536pxwidth >= 96rem

The px column is what the rem values work out to at a 16px root font size. The rem values are the real ones, which matters if a user has changed their browser font size: the layout then switches at a different physical width, which is the behaviour you want.

None of these are device sizes. Nothing about 768px corresponds to a particular tablet, and choosing breakpoints because a specific phone is that wide is how a layout ends up correct on one device and broken on the next. Resize the browser slowly and note where your design actually fails; that number is your breakpoint.

Why md: does not switch off at lg:

This is the single most common misreading. md:flex applies from 768px to infinity. Adding lg:block does not turn it off, it overrides it from 1024px up. If you want a style confined to one band, the next breakpoint has to set it back explicitly:

html
<!-- flex from 768px up, block again from 1024px up -->
<div class="block md:flex lg:block"></div>

Reading a class list left to right, smallest breakpoint first, makes the cascade obvious. It is worth enforcing that order in review, because an out-of-order list still works and still confuses the next reader.

Put together, a card grid that runs one column on phones, two on tablets and three on laptops needs three of the five breakpoints and nothing else:

html
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-8">
  <article class="rounded-lg border p-4">
    <h3 class="text-lg font-semibold">Card one</h3>
    <p class="mt-2 text-sm text-gray-600">Stacks on phones, sits beside its neighbour from 40rem.</p>
  </article>
  <article class="rounded-lg border p-4">
    <h3 class="text-lg font-semibold">Card two</h3>
    <p class="mt-2 text-sm text-gray-600">The gap only grows at 80rem, where there is room for it.</p>
  </article>
  <article class="rounded-lg border p-4">
    <h3 class="text-lg font-semibold">Card three</h3>
    <p class="mt-2 text-sm text-gray-600">No md: anywhere, because nothing needs to change at 48rem.</p>
  </article>
</div>

When should you reach for max-* variants?

Rarely, and mostly for one case. max-md:hidden reads better than a base hidden plus an md:block override when you are hiding something on phones. Otherwise a layout that needs a style fenced into one band usually has a wrong base case, and the fix belongs in the unprefixed class.

Every breakpoint also has a max-* form that applies below it, and stacking the two expresses a single band directly. From the same compiled build:

css
@media (width < 48rem) {
  .max-md\:hidden {
    display: none;
  }
}
@media (width >= 48rem) {
  @media (width < 64rem) {
    .md\:max-lg\:grid {
      display: grid;
    }
  }
}

So max-md:hidden hides an element below 768px, and md:max-lg:grid applies display: grid only between 768px and 1024px, as two nested queries. One-off widths work without touching any config:

html
<div class="min-[320px]:text-center max-[600px]:bg-sky-300"></div>
css
@media (width < 600px) {
  .max-\[600px\]\:bg-sky-300 {
    background-color: var(--color-sky-300);
  }
}
@media (width >= 320px) {
  .min-\[320px\]\:text-center {
    text-align: center;
  }
}

An arbitrary value is emitted exactly as written, so those two stay in px while every named breakpoint stays in rem. That is a second reason to promote a width into @theme once it recurs: the rem guarantee only holds for values that live in the scale.

One-off widths are for genuinely local decisions. If the same odd number turns up in a third component, it has stopped being a one-off and belongs in @theme with a name.

Adding a custom breakpoint in v4

Declare a --breakpoint-* variable inside @theme in your CSS entry file. It adds to the defaults rather than replacing them, and the name becomes the variant prefix:

css
@import "tailwindcss";

@theme {
  --breakpoint-xs: 30rem;   /* 480px */
  --breakpoint-3xl: 120rem; /* 1920px */
}
html
<div class="grid grid-cols-1 xs:grid-cols-2 3xl:grid-cols-6"></div>

Removing one uses the initial keyword, and resetting the whole scale uses the wildcard, which is how you move to named breakpoints:

css
@theme {
  --breakpoint-2xl: initial;  /* drop just 2xl */
}

@theme {
  --breakpoint-*: initial;    /* drop all five, then name your own */
  --breakpoint-tablet: 40rem;
  --breakpoint-laptop: 64rem;
  --breakpoint-desktop: 80rem;
}

Keep every breakpoint in the same unit. Tailwind sorts variants by their value, and mixing rem with px makes that ordering unreliable, which shows up as a variant that quietly loses to another one.

A JavaScript config still works, but v4 no longer loads it automatically. If you are migrating gradually, point at it explicitly with the @config directive, and know that corePlugins, safelist and separator are not supported there any more.

Using Tailwind breakpoints in your own media queries

Nest @variant md { ... } inside the rule, or wrap the rule in @media (width >= theme(--breakpoint-md)). Both compile to the same width >= 48rem query the md: class uses, and both follow your @theme. var(--breakpoint-md) and the v3 @screen md look equivalent and never match.

You need this when a rule belongs in a stylesheet rather than a class list: a third-party widget you cannot add classes to, or a selector Tailwind has no utility for. The two that work and the one that does not, side by side:

css
@import "tailwindcss";

.sidebar {
  display: none;
  @variant md {
    display: block;
  }
}

@media (width >= theme(--breakpoint-lg)) {
  .sidebar {
    width: 16rem;
  }
}

@media (width >= var(--breakpoint-xl)) {
  .sidebar {
    width: 20rem;
  }
}

Tailwind 4.3.3 compiles those three rules to:

css
.sidebar {
  display: none;
  @media (width >= 48rem) {
    display: block;
  }
}
@media (width >= 64rem) {
  .sidebar {
    width: 16rem;
  }
}
@media (width >= var(--breakpoint-xl)) {
  .sidebar {
    width: 20rem;
  }
}

@variant md becomes the same range query a md: class would produce, and theme(--breakpoint-lg) is replaced with its value at build time. Both follow your @theme, so a custom breakpoint works here too. Reach for @variant first, because it sits right next to the base style it overrides, and keep theme() for a top-level block that wraps several selectors.

The third block is the trap. It looks right and the development build passes it through without a word, but var() is only valid in property values, not in a media query, so the browser never matches it and the 20rem width never applies. The only clue comes from a production build, where the Lightning CSS pass prints a warning ending in Unexpected token Function("var").

The v3 @screen md directive fails the same way. v4 copies it into the output untouched, the browser ignores an at-rule it does not know, and the styles inside it disappear. A production build warns Unknown at rule: @screen; a development build says nothing. The v3 @media screen(md) form is passed through the same way. If a migrated stylesheet has rules that stopped applying, search it for @screen and screen( first and replace each one with @variant.

One more failure, and it hits the exact case this section started with. A CSS module or a component's <style> block is compiled on its own and has never seen your theme, so @variant md fails the build with Cannot use `@variant` with unknown variant: md. Put @reference at the top, pointing at your main stylesheet so custom breakpoints come with it. It makes the theme available without emitting the CSS a second time:

css
/* Sidebar.module.css */
@reference "../app/globals.css";

.sidebar {
  display: none;
  @variant md {
    display: block;
  }
}

Breakpoint or container query?

Breakpoints answer "how wide is the window". Container queries answer "how wide is this component's parent", which is the question a reusable card actually cares about. A card in a 320px sidebar and the same card in a 900px main column see identical breakpoints and need different layouts. Container queries moved into core in v4, so there is no plugin to install.

html
<div class="@container">
  <div class="flex flex-col @md:flex-row @max-sm:hidden"></div>
</div>

And here is the part worth knowing before you ship it:

css
@container (width < 24rem) {
  .\@max-sm\:hidden {
    display: none;
  }
}
@container (width >= 28rem) {
  .\@md\:flex-row {
    flex-direction: row;
  }
}

Named containers keep that readable once components nest, and arbitrary values work the same way as with breakpoints:

html
<div class="@container/card">
  <div class="flex flex-col @min-[475px]:flex-row @lg/card:gap-8"></div>
</div>
SituationReach for
Page shell: header, sidebar, main gridmd: lg:
A component reused at different widths@container + @md:
Hide something on phones onlymax-md:hidden
One band, nothing above or belowmd:max-lg:
A single odd width in one placemin-[420px]:
That width appears in three or more components--breakpoint-xs in @theme

Container queries have a real cost: the parent needs @container, which sets container-type: inline-size, and that establishes containment on the inline axis. An element whose height depends on its own content is fine; one you were relying on to size from its children in the inline direction is not. Add it at the component boundary, not on every wrapper.

What happened to purge and tailwind.config.js?

Both are gone from the normal path. There is no purge step to configure in v4, because the engine only generates the classes it finds in your source. Content paths are detected automatically, using your .gitignore to skip what it should not read, and @source exists for the exceptions, such as class names inside a dependency:

css
@import "tailwindcss";

/* scan a dependency the heuristics skip */
@source "../node_modules/@my-company/ui-kit";

To turn automatic detection off entirely and list the sources yourself, put it on the import:

css
@import "tailwindcss" source(none);

@source "../src";

The scanner matches literal strings. It does not evaluate your code, so a class name assembled at runtime produces no CSS at all. I compiled this file with Tailwind 4.3.3 to check what actually happens:

html
<p class="text-${size}">dynamic class name, built at runtime</p>

The other v4 moves that break old snippets are worth knowing before you copy one: dark mode is configured with @custom-variant dark (&:where(.dark, .dark *)) rather than darkMode: 'class', and the container utility lost its center and padding options, which now live in an @utility container block.

css
@custom-variant dark (&:where(.dark, .dark *));

@utility container {
  margin-inline: auto;
  padding-inline: 2rem;
}

The payoff for all this rearranging is build time. Tailwind's own v4 announcement measures full builds at 378ms in v3.4 against 100ms in v4.0, and incremental rebuilds that add no new CSS at 35ms against 192 microseconds. On a page in a React app, CSS is rarely what makes it feel slow anyway; unnecessary re-renders usually are, which is a different problem covered in useMemo, useCallback and React.memo.

What I reach for

Start with the five defaults and change nothing. Most designs need two or three of them, and a scale everyone already knows is worth more than one tuned to a single mockup. Add a custom breakpoint only when the same awkward width shows up in several components and sits more than about 50px away from an existing value, because every extra breakpoint is one more thing each future contributor has to learn and respect.

Reach for a container query when the component is genuinely reused at different widths, and remember the 28rem surprise when you do. Keep max-* variants for the cases where they read better than an override, not as a general habit. In a React codebase the layout decisions end up encoded as typed props on the component, which is covered in React and TypeScript.

Two limits worth stating plainly. I have not measured container-query performance on a page with hundreds of containers, so treat the containment cost above as a reason to be deliberate rather than a benchmark. And the compiled output in this post comes from Tailwind 4.3.3. Its shape has already changed once inside v4, so check it against your own build before relying on the exact form. The breakpoint and container values were the same in 4.1.18 and 4.3.3.

Further reading

Frequently asked questions

What are the default breakpoints in Tailwind CSS?

Five: sm at 640px, md at 768px, lg at 1024px, xl at 1280px, and 2xl at 1536px. Each one is a min-width, so a prefix applies at that size and every size above it. Unprefixed classes apply everywhere, which is the part that trips people up: the unprefixed class is the mobile case, and the prefixed one is an override layered on top of it from 768px up. Tailwind v4 moved these into CSS as @theme variables rather than a JavaScript config object, so a custom scale now sits alongside your other design tokens, but the five values themselves are unchanged from v3. They are also deliberately not device sizes. Pick from where your own layout breaks: sm and lg alone carry most designs, and 2xl is the step most projects never use.

Why does my md: class still apply on a large screen?

Because Tailwind is mobile-first and every breakpoint is a min-width. md: means 768px and up, not 768px to 1024px. Adding lg: does not turn md: off, it overrides it above 1024px. If you want a style to apply only inside a band, the next breakpoint has to set it back explicitly, so md:flex lg:block rather than md:flex on its own. Tailwind also ships max-width variants for the genuine cases: max-md: applies below 768px, and stacking md:max-lg: expresses a single band directly. In the compiled CSS that stacking becomes two nested media queries rather than one combined range, which is worth knowing if you ever read the output while chasing a specificity problem.

How do I add a custom breakpoint?

Extend the scale rather than replacing it. In v4 that means declaring a --breakpoint-* variable inside @theme, which adds to the defaults; in v3 it was the screens key under theme.extend. Putting screens directly under theme instead of theme.extend drops all five defaults, and you will not notice until a sm: class stops working somewhere nobody touched. Custom breakpoints earn their place when a real layout in your design breaks at a specific width, not because a device happens to be that wide. If that width lands within about 50px of an existing breakpoint, use the existing one, because every custom breakpoint is one more value each future contributor has to learn and respect. Name it for the layout problem rather than the device, and keep it in rem so it sorts alongside the defaults.

Do I still need to configure purging in Tailwind v4?

No. The just-in-time engine only generates the classes it finds in your source, so unused styles are never built in the first place and there is no purge step left to configure. What still matters is that Tailwind can see your class names. Names assembled from fragments at runtime are invisible to the scanner, because it matches literal strings rather than evaluating your code, so a template built from a colour variable produces nothing at all. Write the full names out and select between them, even when that feels repetitive. In v4 the content paths are detected automatically for most projects, which removes the other half of the old configuration, but the same constraint holds. If a style works in development and vanishes in production, a dynamically built class name is the first thing to check.

When should I use a container query instead of a breakpoint?

Use a breakpoint for the page shell and a container query for anything reused at more than one width. A breakpoint asks how wide the window is, which is the right question for a header or a page grid. A container query asks how wide this component's parent is, which is what a card in a 320px sidebar and the same card in a 900px column actually differ on. In Tailwind v4 container queries are in core: put @container on the parent and prefix the child with @md: or @max-sm:. Watch the numbers, because container variants read the container scale rather than the breakpoint scale, so @md: fires at 28rem while md: fires at 48rem. The cost is that @container sets container-type: inline-size on the parent, which establishes inline containment, so add it at component boundaries rather than on every wrapper.

How do I use Tailwind breakpoints in a CSS media query?

In Tailwind v4, nest @variant md { ... } inside your rule, or write @media (width >= theme(--breakpoint-md)) around it. Both are replaced at build time with the same width >= 48rem query the md: class uses, and both pick up custom breakpoints from @theme. Do not write var(--breakpoint-md) inside the media query: var() is only valid in property values, so the query never matches, and only a production build warns about it. The v3 @screen md directive is gone too; v4 passes it through untouched and the browser ignores it. In a CSS module or a component style block, add @reference pointing at your main stylesheet first, or @variant fails with an unknown variant error.

Related Articles