Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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

PrefixMin widthWhat v4 actually emitsTypical role
(none)0no media querythe 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. sm through 2xl still exist alongside xsm.
  • --breakpoint-*: initial deletes one. To start from an empty scale, --breakpoint-*: initial; first, then declare yours.
  • Turbopack does not hot-reload @theme changes. Edit a token and restart npm 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:

PrefixUsesFiles
sm:3319
md:12343
lg:8031
xl:8512
2xl:42
xsm: / 2xsm:00

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 seamssm/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:

  • 425px duplicates the xsm token's value as a literal. The token exists; the rule that needs it does not use it.
  • 1440px corresponds 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

MistakeHow it shows upFix
Repeating a value at every larger breakpointmd:p-6 lg:p-6 xl:p-6 in the diffSet it once; min-width cascades upward
Editing @theme and not restarting the dev serverThe new variant produces no CSS, reads as a typoRestart npm run dev — Turbopack does not hot-reload @theme
Treating breakpoints as devices"iPad is 768px" logic that breaks on the next deviceSet the threshold where your layout stops fitting
Declaring a breakpoint you never useDead design intent the next person trustsDelete it, or use it
Hand-writing media queries next to the token scaleTwo sources of truth drift apart silentlyReference the token, or generate the rule
Reaching for max-* by defaultTwo rules per decision instead of onemax-* only where a rule exists below a threshold and nowhere above
Sizing a component by the viewport when it lives in a gridPadding grows while the card shrinksUse 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.

Keep reading

Tutorial9 min read

Tailwind Button: 15 Utilities, 3 Variants, and Zero Buttons

The component called Button renders a link all 22 times it is used, while 44 real button elements sit elsewhere. The full class list, and the disabled state it was missing.

Read more
Tutorial10 min read

What Is a Tailwind Class? 658 className Attributes, One Compiler Rule

A Tailwind class is a compiler-generated CSS rule that only exists because a literal string appeared in your source. Audited from 658 className attributes and 91 components.

Read more