Tailwind Breakpoints: 325 Variants, Two Dead Ones, and Three Different Seams
The default scale, how v4 custom breakpoints work in @theme, and what a census of one production codebase says about where the seams actually land.
Tailwind's five default breakpoints are sm 640px, md 768px, lg 1024px, xl 1280px and 2xl 1536px, and every one is a mobile-first min-width query: md:flex means "flex at 768px and above." In Tailwind v4 you add your own by declaring --breakpoint-* inside @theme, not in a config file.
The default scale, and what a prefix compiles to
| Prefix | Min width | What v4 actually emits | Typical role |
|---|---|---|---|
| (none) | 0 | no media query | the mobile baseline |
sm: | 640px | @media (width >= 40rem) | large phone / small tablet |
md: | 768px | @media (width >= 48rem) | tablet — the usual mobile/desktop seam |
lg: | 1024px | @media (width >= 64rem) | laptop |
xl: | 1280px | @media (width >= 80rem) | desktop |
2xl: | 1536px | @media (width >= 96rem) | wide desktop |
That third column is measured, not quoted: compiling md:flex against this project's stylesheet emits @media (width >= 48rem), not the min-width form v3 produced. Two consequences follow. The default scale is expressed in rem, so it scales with the user's root font size rather than pinning to CSS pixels — 768px is what 48rem resolves to at the default 16px root, not a hard-coded value. And v4 uses CSS media range syntax, which is why the max-* variants below need no half-pixel fudge.
Mobile-first is the part people fight rather than use. An unprefixed utility applies everywhere; a prefixed one applies from that width up and cascades to every larger breakpoint. p-4 md:p-6 is 16px of padding on a phone and 24px from 768px onward, forever — there is no lg: needed to "keep" it. Writing md:p-6 lg:p-6 xl:p-6 is the single most common Tailwind responsive mistake, and it is invisible in the browser because it produces the right result for the wrong reason.
When you genuinely need the other direction, max-* variants query below a threshold: max-md:hidden compiles to @media (width < 48rem). This codebase uses 33 of them across src/, against 325 min-width variants — roughly a 1-in-10 ratio, which is about the right shape. max-* earns its place when a rule exists only below a threshold, like the floating device toggle in the storefront's preview modal:
// src/components/molecules/PreviewModal.tsx
className="z-20 order-3 flex shrink-0 items-center gap-1 rounded-full border ...
max-md:absolute max-md:bottom-[max(0.75rem,env(safe-area-inset-bottom))]
max-md:left-1/2 max-md:-translate-x-1/2 md:order-2 md:shadow-none"
Below 768px the toggle floats over the iframe so it does not eat a third header row; at 768px it becomes an ordinary flex child. Expressed mobile-first that would need four utilities plus four md: resets.
Custom breakpoints in Tailwind v4: @theme, not tailwind.config.js
v4 moved theme configuration into CSS. There is no tailwind.config.js in this repository at all; the whole design system is a @theme block in src/app/globals.css, and breakpoints are namespaced tokens inside it:
/* src/app/globals.css */
@theme {
--font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
/* …colour scales… */
--breakpoint-2xsm: 375px;
--breakpoint-xsm: 425px;
}
The token name after --breakpoint- becomes the variant prefix, so those two lines are what would make xsm:grid-cols-2 a valid class. Three rules follow from the namespace design and are worth knowing before you fight the tooling:
- Adding a token extends the default scale; it does not replace it.
smthrough2xlstill exist alongsidexsm. --breakpoint-*: initialdeletes one. To start from an empty scale,--breakpoint-*: initial;first, then declare yours.- Turbopack does not hot-reload
@themechanges. Edit a token and restartnpm run dev, or the new variant silently fails to generate — which reads exactly like a typo and costs an afternoon.
What 325 responsive variants look like in a real codebase
Counted across src/ on 2026-08-26, in a storefront of 91 components:
| Prefix | Uses | Files |
|---|---|---|
sm: | 33 | 19 |
md: | 123 | 43 |
lg: | 80 | 31 |
xl: | 85 | 12 |
2xl: | 4 | 2 |
xsm: / 2xsm: | 0 | 0 |
Two things in that table are worth more than the total.
md: is 38% of every responsive decision on the site, spread across 43 files. That is the mobile/desktop seam, and it is where the load-bearing layout changes live. The product page is the clearest case: below md it lifts the editions block above the prose so "Live preview" lands directly under the carousel on a phone, using contents so the buy column's children become orderable grid items — the mechanism is walked through in Tailwind grid.
// src/components/organisms/TemplateDetail.tsx
<div className="mx-auto mt-12 grid max-w-[1060px] gap-10 md:grid-cols-[1.4fr_1fr]">
<div className="order-2 md:order-none">{/* prose */}</div>
<div className="contents md:block md:space-y-8">
<div className="order-1 md:order-none">{/* editions picker */}</div>
<div className="order-3 space-y-3 md:order-none">{/* buy box */}</div>
<div className="order-4 md:order-none" id="changelog">{/* changelog */}</div>
</div>
</div>
xl: is concentrated, not spread: 85 uses in only 12 files, and 37 of those 85 are in Header.tsx alone. The site's navigation collapses to a hamburger at xl (1280px), not at md or lg, because the desktop nav plus the account controls genuinely do not fit below that. One component sets the seam for the entire site's chrome, and it is a different seam from the one 43 other files use.
That is the honest finding: a real codebase does not have "a" breakpoint. This one has three seams — sm/lg for card grids, md for page layout, xl for global navigation — and they are all defensible, because they are each set by what actually stops fitting at that width. The failure mode is not having several. It is having several by accident.
Two breakpoints declared and never used
--breakpoint-2xsm: 375px and --breakpoint-xsm: 425px are declared in @theme and used zero times anywhere in src/. Grep the whole tree and the only hits are the two declaration lines themselves.
In v4 this costs nothing in shipped CSS — utilities are generated on demand from scanned source, so a variant nobody writes produces no rule. It costs something else. A token in @theme reads as a design decision to the next person, and these two assert that this design system has meaningful thresholds at 375px and 425px. It does not. Every component treats "phone" as one undifferentiated baseline.
The tell is thirty lines below, in the hand-written container rule:
/* src/app/globals.css */
.container { width: 100%; margin-inline: auto; padding-inline: 1rem; }
@media (min-width: 425px) { .container { max-width: 425px; } }
@media (min-width: 640px) { .container { max-width: 640px; } }
@media (min-width: 768px) { .container { max-width: 768px; } }
@media (min-width: 1024px) { .container { max-width: 1024px; } }
@media (min-width: 1280px) { .container { max-width: 1280px; } }
@media (min-width: 1440px) { .container { padding-inline: 4rem; } }
@media (min-width: 1536px) { .container { max-width: 1536px; padding-inline: 3rem; } }
The wrapper every page renders inside has seven hand-written media queries, and the scale is a third one, agreeing with neither of the other two:
425pxduplicates thexsmtoken's value as a literal. The token exists; the rule that needs it does not use it.1440pxcorresponds to nothing at all — not a default breakpoint, not a declared token. It is the width at which someone decided the gutters should grow, and it lives only here.- The remaining five mirror the defaults exactly, which means any future change to the token scale silently desynchronises the container from the utilities.
None of this is a rendering bug — the page is correct at every width, and the site scores accessibility and SEO 100 on desktop across all eight main pages. It is a maintenance defect, and the reason to name it is that it is the shape breakpoint drift always takes: a token, a literal, and a third value that only one file knows about. The fix is mechanical — @media (min-width: theme(--breakpoint-xsm)) for the 425px tier, a declared --breakpoint-3xl: 1440px for the gutter tier, and delete 2xsm — and it is deliberately not applied inside a post about it, the same convention this codebase used for the dynamicParams asymmetry.
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
| Repeating a value at every larger breakpoint | md:p-6 lg:p-6 xl:p-6 in the diff | Set it once; min-width cascades upward |
Editing @theme and not restarting the dev server | The new variant produces no CSS, reads as a typo | Restart npm run dev — Turbopack does not hot-reload @theme |
| Treating breakpoints as devices | "iPad is 768px" logic that breaks on the next device | Set the threshold where your layout stops fitting |
| Declaring a breakpoint you never use | Dead design intent the next person trusts | Delete it, or use it |
| Hand-writing media queries next to the token scale | Two sources of truth drift apart silently | Reference the token, or generate the rule |
Reaching for max-* by default | Two rules per decision instead of one | max-* only where a rule exists below a threshold and nowhere above |
| Sizing a component by the viewport when it lives in a grid | Padding grows while the card shrinks | Use container queries for component-relative sizing |
Frequently asked questions
How do I add a custom breakpoint in Tailwind v4?
Declare --breakpoint-<name>: <length>; inside your @theme block in CSS. There is no tailwind.config.js in v4 by default — this repository has none — and the token name becomes the variant prefix directly, so --breakpoint-xsm: 425px yields xsm:* utilities. Restart the dev server afterwards.
Which breakpoint should I use for the mobile/desktop switch?
Whichever one your layout actually breaks at, and expect it to differ per component. In this codebase the page grid switches at md (768px) while the global navigation switches at xl (1280px), because the nav has far more horizontal content to fit than the article grid does. Picking one number for the whole site is how you end up with a squashed nav or a stretched column.
What is the difference between md: and max-md:?
md: compiles to @media (width >= 48rem) and applies from 768px up; max-md: compiles to @media (width < 48rem) and applies below it. They are complements, not opposites: md:hidden hides the element on desktop, max-md:hidden hides it on mobile. Because v4 uses range syntax rather than v3's not all and (min-width: …), the boundary is exact — the two can never both match at 768px, and no .98px fudge is involved. Which display value the element returns to when it isn't hidden is its own decision, counted across this codebase here.
Do unused breakpoint tokens bloat the CSS bundle? Not in v4. Utilities are generated on demand from the classes found in your source, so a declared-but-unwritten variant emits nothing — the two unused tokens measured above add zero bytes. The cost is comprehension, not payload, which is a slower and more expensive kind of cost.
When should I use container queries instead?
When the element's correct size depends on the space it occupies rather than the window. A card in a grid that goes 2-up at md and 3-up at lg is narrower on a laptop than on a tablet, so viewport-keyed padding on it is wrong by construction. We found exactly that inversion on our own home page — the container-query audit is here.
Templates built on this token scale
ASoc Compound, ASoc Cortex and ASoc Fiscal ship the same Tailwind v4 CSS-first setup measured here — a @theme block instead of a config file, mobile-first variants throughout, and a responsive container that every section renders inside.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates. If you are moving an existing project onto this setup, the Tailwind v4 migration guide covers what @theme replaces; how the colour half of the same token block works covers the scale beside these breakpoints.
