Tailwind Max Width: 62 Usages, 57 Arbitrary, and 96 Lost Pixels
max-w-* reads the --container-* scale in v4, and max-w-md is 448px, not 768px. A census of 62 usages here, plus the viewport band where our layout narrows as it widens.
max-w-* sets a CSS max-width. In Tailwind v4 the named scale — max-w-xs through max-w-7xl — is not its own scale at all: it reads the --container-* theme variables, so max-w-3xl compiles to max-width: var(--container-3xl), which is 48rem. Arbitrary values use bracket syntax: max-w-[870px].
This storefront uses max-w-* 62 times across 34 files. Fifty-seven of those are arbitrary pixel values. That ratio looks like a smell and mostly is not one, for a reason worth explaining — and the audit that produced it found a 96-pixel band where the layout gets narrower as the window gets wider.
The v4 scale, measured rather than remembered
The named rungs come straight out of node_modules/tailwindcss/theme.css. Compiling max-w-3xl against tailwindcss 4.3.1 emits:
.max-w-3xl {
max-width: var(--container-3xl);
}
The full scale, and the trap in the middle of it:
| Utility | Token | Value | px @ 16px root |
|---|---|---|---|
max-w-3xs | --container-3xs | 16rem | 256 |
max-w-2xs | --container-2xs | 18rem | 288 |
max-w-xs | --container-xs | 20rem | 320 |
max-w-sm | --container-sm | 24rem | 384 |
max-w-md | --container-md | 28rem | 448 |
max-w-lg | --container-lg | 32rem | 512 |
max-w-xl | --container-xl | 36rem | 576 |
max-w-2xl | --container-2xl | 42rem | 672 |
max-w-3xl | --container-3xl | 48rem | 768 |
max-w-4xl | --container-4xl | 56rem | 896 |
max-w-5xl | --container-5xl | 64rem | 1024 |
max-w-6xl | --container-6xl | 72rem | 1152 |
max-w-7xl | --container-7xl | 80rem | 1280 |
max-w-md is 448px, not 768px. It shares a name with the md breakpoint and nothing else — the md breakpoint is 48rem/768px, which is max-w-3xl. Two different scales, five overlapping labels. This is the single most common max-w mistake, and the fact that both scales exist in the same @theme block is why.
Because they are ordinary theme variables, redeclaring one in your own @theme moves every max-w-*, w-* and min-w-* that references it. That is a feature when you want a design-wide measure, and a surprise when you only meant to change one card.
max-w-screen-* was not deleted in v4
Widely repeated, and wrong as of 4.3.1. Compiling max-w-screen-md against the installed version still produces output:
.max-w-screen-md {
max-width: var(--breakpoint-md);
}
It survives as a deprecated compatibility utility pointing at the breakpoint token. The forward spelling for the same intent is the arbitrary-property syntax, which reads the token directly and does not depend on a deprecation surviving another minor:
<div class="max-w-(--breakpoint-md)">…</div>
If you have max-w-screen-* in a v4 codebase it is not broken today. It is a rename you can do on your own schedule rather than an outage.
The census: 62 usages, 57 arbitrary
Run against src/, excluding the article prose in src/content/blog/:
$ grep -rEo "max-w-[A-Za-z0-9._%/-]+|max-w-\[[^]]+\]" src \
--include=*.tsx --include=*.ts --include=*.css \
| grep -v '^src/content/blog' > /tmp/maxw.txt
$ wc -l < /tmp/maxw.txt
62
$ cut -d: -f2- /tmp/maxw.txt | sort | uniq -c | sort -rn | head -7
8 max-w-[870px]
6 max-w-[570px]
5 max-w-[760px]
3 max-w-[880px]
3 max-w-[770px]
3 max-w-[720px]
3 max-w-[1060px]
Five named utilities in the whole codebase — max-w-xs, max-w-sm, max-w-3xl, max-w-none, max-w-max — against 57 arbitrary values covering 28 distinct pixel numbers and one percentage. Zero max-w-screen-*. Zero max-w-full.
The instinct is to call that a token failure and map everything onto the named scale. Look at where the numbers land before you do:
| Design value | rem | Nearest rung above | Nearest rung below |
|---|---|---|---|
| 870px | 54.375 | max-w-4xl (896) | max-w-3xl (768) |
| 760px | 47.5 | max-w-3xl (768) | max-w-2xl (672) |
| 570px | 35.625 | max-w-xl (576) | max-w-lg (512) |
The --container-* scale is modular — it steps 16, 18, 20, 24, 28, 32, 36, 42, 48, 56, 64, 72, 80rem, roughly a quarter at a time near the top. A design's measure is not a modular quantity; it is "how many characters fit on a line before reading gets worse," and it lands wherever it lands. Snapping 870 to 896 to win a nicer class name changes the rendered design to make the CSS tidier. That is the wrong trade.
Where the census does show a real problem is the repetition. max-w-[870px] appearing eight times is a design token that was never declared. The fix is one line in @theme:
@theme {
--container-section: 870px;
--container-prose: 760px;
}
…which makes max-w-section and max-w-prose real utilities, at the design's own values, with no rounding. In v4 you do not choose between the named scale and the true number. You name the true number.
The two jobs max-w does, and why this codebase keeps them apart
Every section in this repository composes the same two layers:
// src/components/organisms/ArticleBody.tsx
<section className="pb-16 md:pb-20">
<Container>
<div className="mx-auto w-full max-w-[760px]">{children}</div>
</Container>
</section>
Container (src/components/atoms/Container.tsx) is the page shell — how wide the site is allowed to get, plus the gutter. The inner max-w-[760px] is the measure — how wide this particular content should be for reading. The shell is global and appears once; the measure is local and differs per section. Thirty-one files render Container; all 57 arbitrary values live inside one.
Keeping them separate is why max-w-[870px] can repeat eight times without being a bug: it is the same editorial decision (a centred section header) reached in eight sections, not eight copies of a layout constant. w-full beside it matters too — max-width alone does not make a block fill the space below the cap in every flex or grid context, and mx-auto is what actually centres it.
The defect: a 96px band where wider means narrower
The page shell is a hand-written rule in src/app/globals.css, not Tailwind's container utility:
.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; } }
Read the 1440 tier carefully. It raises the gutter to 4rem and sets no max-width, so the cap stays at the 1280px inherited from the tier below. Preflight puts everything in box-sizing: border-box, so padding comes out of that cap. The usable content width:
| Viewport | max-width | Gutter (each side) | Content width |
|---|---|---|---|
| 1280–1439 | 1280px | 16px | 1248px |
| 1440–1535 | 1280px (inherited) | 64px | 1152px |
| ≥ 1536 | 1536px | 48px | 1440px |
Content width goes 1248 → 1152 → 1440. Drag a 1439px window one pixel wider and the content column loses 96 pixels, 7.7% of itself. It is not a rendering bug; it is arithmetic, and it is invisible in every screenshot because nobody screenshots two viewports 1px apart.
The consequence is that two inner constraints go dead in that band. max-w-[1170px] in FeatureTabs.tsx and max-w-[1200px] in src/app/dashboard/layout.tsx both bind at 1280–1439 (1248px of room) and again at ≥1536 (1440px of room), but between 1440 and 1535 the shell is 1152px — narrower than either cap. The declared widths are unreachable there, so the code says one thing and the layout does another.
Either the 1440 tier lost a max-width line, or the padding jump is deliberate breathing room. We have not changed it in this post, because altering the shell width silently re-lays-out all 336 prerendered routes of this site and that is not a blog-post-sized edit. It is written down here so the next person reading FeatureTabs.tsx knows why the number they see is not the number that renders. The one-line version, if you hit the same shape:
@media (min-width: 1440px) { .container { max-width: 1440px; padding-inline: 4rem; } }
The other finding: 393 bytes of dead container CSS
Because the override is a plain .container rule while Tailwind's is generated inside @layer utilities, the compiled stylesheet ships both. From the real production build:
Tailwind's container utility ... 393 bytes
the globals.css override ....... 463 bytes
total stylesheet ............... 73,464 bytes
Walking the compiled file confirms the split: Tailwind's rule sits inside @layer utilities, the override sits in no layer at all. Unlayered normal declarations outrank layered ones whatever the source order, so all 393 bytes of the generated utility are unreachable — 0.5% of the CSS, downloaded on every page, and eight media-query blocks a maintainer has to disambiguate before they can trust the other eight.
There is a second-order effect worth knowing about. Tailwind's container reads --breakpoint-*, and this repo declares two custom ones (--breakpoint-2xsm: 375px, --breakpoint-xsm: 425px). The generated utility grew tiers for both:
.container{width:100%}
@media (min-width:375px){.container{max-width:375px}}
@media (min-width:425px){.container{max-width:425px}}
@media (min-width:40rem){.container{max-width:40rem}}
…
Adding a breakpoint token in v4 widens the container utility as a side effect. In a codebase that actually uses container, that is a layout change nobody wrote. The clean fix is to stop shadowing the class and define it once, which is exactly what @utility is for:
@utility container {
width: 100%;
margin-inline: auto;
padding-inline: 1rem;
/* …the project's own tiers, and no duplicate */
}
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
Reading max-w-md as the md breakpoint | An element caps at 448px when you expected 768px | max-w-3xl is 48rem; the scales only share labels |
max-w-* with no mx-auto | Correct width, pinned left, "the centring is broken" | max-width caps; margin-inline: auto centres |
max-w-* with no w-full | Block collapses to content width in a flex parent | Pair them: w-full max-w-[760px] |
| Snapping design values to the named scale | The rendered design shifts to make classes prettier | Declare --container-<name> at the true value |
| An inner cap wider than the shell's content box | The declared number never renders; nothing errors | Check the shell's max-width minus its padding |
Raising a container's padding without its max-width | Content narrows as the viewport widens | Set both in the same tier |
Shadowing .container with a plain rule | Two rule sets ship; one is dead bytes | Redefine it with @utility container |
Assuming max-w-screen-* was removed in v4 | A migration nobody needed to do this week | It still compiles, to var(--breakpoint-*) |
Frequently asked questions
What is the difference between w- and max-w- in Tailwind?
w- sets width — a demand. max-w- sets max-width — a ceiling that only takes effect when the available space exceeds it. Responsive layouts almost always want the ceiling, because the element should shrink freely on a phone and stop growing on a desktop. w-full max-w-[760px] is the canonical pair: take all the width you can get, up to 760px.
How do I set a custom max width in Tailwind?
Two ways, and they are not equivalent. One-off: bracket syntax, max-w-[870px], which compiles a single utility. Repeated: declare --container-<name> in your @theme block and use max-w-<name>, which gives the value a name, one place to change it, and the same value to w- and min-w-. Our census says the switch point is roughly the third occurrence — max-w-[870px] appearing eight times is a token that should have been declared.
Is max-w-full the same as w-full?
No. max-w-full is max-width: 100%, which stops an element overflowing its parent but does not make it fill anything — it is the standard guard on images and embeds. w-full is width: 100%, a demand to fill. This codebase uses max-w-full zero times because the images are already constrained by their containers.
Why is my max-w-* being ignored?
Usually one of three things. The parent is narrower than your cap, so the cap never binds — the case we found at 1440px above, where a 1152px shell makes a max-w-[1200px] child unreachable. Or the element is a flex/grid item whose flex-basis or track sizing is deciding its width. Or another rule wins the cascade: an unlayered plain-CSS rule beats a @layer utilities one regardless of source order, which is precisely how the .container override in this repo overrides Tailwind's.
Should the page shell use Tailwind's container or a custom rule?
Use Tailwind's, customised through @utility container, unless you need behaviour it cannot express. A parallel hand-written .container costs you a duplicate rule set in the bundle and a cascade puzzle for the next reader. It is also brittle in v4 specifically: the generated utility picks up every --breakpoint-* token you declare, so the two definitions drift apart as the theme grows.
Templates that ship this layout system
ASoc Timbre, ASoc Vox and ASoc Weave are built on the same two-layer arrangement measured here — a responsive shell every section renders inside, with per-section measure caps set at the design's real values rather than snapped to a scale.
Browse the sets: Next.js landing page templates and Tailwind landing page templates. The breakpoint half of this token block is audited in Tailwind Breakpoints: 325 Variants, Two Dead Ones; when the correct width depends on the element's slot rather than the window, the container-query audit covers the inversion this post's shell arithmetic hints at. The type half of the same @theme block is audited in Tailwind font-family, including why --font-sans has to be fed from outside Tailwind.
