Skip to content

Commit dd85aad

Browse files
Reintroduce container component as a utility (#14993)
Closes #13129 We're adding back the v3 `container` component, this time as a utility. The idea is that we support the default `container` behavior but we will not have an API to configure this similar to what v3 offered. Instead, the recommended approach is to configure it by creating a custom utility like so: ```css @import "tailwindcss"; @Utility container { margin-left: auto; margin-right: auto; padding-left: 2rem; padding-right: 2rem; } ``` We do have an idea of how to migrate existing JS configuration files to the new `@utility` as part of the interop layer and the codemod. This is going to be a follow-up PR though. ## Test Plan We added a unit test but we've also played around with it in the Vite playground. Yep, looks like a `container`: https://github.com/user-attachments/assets/ea7a5a4c-4cde-4ef5-9062-03e16239eb85 --------- Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
1 parent dda181b commit dd85aad

File tree

7 files changed

+299
-54
lines changed

7 files changed

+299
-54
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Support opacity values in increments of `0.25` by default ([#14980](https://github.com/tailwindlabs/tailwindcss/pull/14980))
1313
- Support specifying the color interpolation method for gradients via modifier ([#14984](https://github.com/tailwindlabs/tailwindcss/pull/14984))
14+
- Reintroduce `container` component as a utility ([#14993](https://github.com/tailwindlabs/tailwindcss/pull/14993))
1415

1516
### Fixed
1617

packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -3375,6 +3375,7 @@ exports[`getClassList 1`] = `
33753375
"contain-size",
33763376
"contain-strict",
33773377
"contain-style",
3378+
"container",
33783379
"content-around",
33793380
"content-baseline",
33803381
"content-between",

packages/tailwindcss/src/property-order.ts

+4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export default [
2828
'float',
2929
'clear',
3030

31+
// Ensure that the included `container` class is always sorted before any
32+
// custom container extensions
33+
'--tw-container-component',
34+
3135
// How do we make `mx-0` come before `mt-0`?
3236
// Idea: `margin-x` property that we compile away with a Visitor plugin?
3337
'margin',

packages/tailwindcss/src/utilities.test.ts

+225-1
Original file line numberDiff line numberDiff line change
@@ -3148,6 +3148,230 @@ test('max-height', async () => {
31483148
).toEqual('')
31493149
})
31503150

3151+
describe('container', () => {
3152+
test('creates the right media queries and sorts it before width', async () => {
3153+
expect(
3154+
await compileCss(
3155+
css`
3156+
@theme {
3157+
--breakpoint-sm: 40rem;
3158+
--breakpoint-md: 48rem;
3159+
--breakpoint-lg: 64rem;
3160+
--breakpoint-xl: 80rem;
3161+
--breakpoint-2xl: 96rem;
3162+
}
3163+
@tailwind utilities;
3164+
`,
3165+
['w-1/2', 'container', 'max-w-[var(--breakpoint-sm)]'],
3166+
),
3167+
).toMatchInlineSnapshot(`
3168+
":root {
3169+
--breakpoint-sm: 40rem;
3170+
--breakpoint-md: 48rem;
3171+
--breakpoint-lg: 64rem;
3172+
--breakpoint-xl: 80rem;
3173+
--breakpoint-2xl: 96rem;
3174+
}
3175+
3176+
.container {
3177+
width: 100%;
3178+
}
3179+
3180+
@media (width >= 40rem) {
3181+
.container {
3182+
max-width: 40rem;
3183+
}
3184+
}
3185+
3186+
@media (width >= 48rem) {
3187+
.container {
3188+
max-width: 48rem;
3189+
}
3190+
}
3191+
3192+
@media (width >= 64rem) {
3193+
.container {
3194+
max-width: 64rem;
3195+
}
3196+
}
3197+
3198+
@media (width >= 80rem) {
3199+
.container {
3200+
max-width: 80rem;
3201+
}
3202+
}
3203+
3204+
@media (width >= 96rem) {
3205+
.container {
3206+
max-width: 96rem;
3207+
}
3208+
}
3209+
3210+
.w-1\\/2 {
3211+
width: 50%;
3212+
}
3213+
3214+
.max-w-\\[var\\(--breakpoint-sm\\)\\] {
3215+
max-width: var(--breakpoint-sm);
3216+
}"
3217+
`)
3218+
})
3219+
3220+
test('sorts breakpoints based on unit and then in ascending aOrder', async () => {
3221+
expect(
3222+
await compileCss(
3223+
css`
3224+
@theme reference {
3225+
--breakpoint-lg: 64rem;
3226+
--breakpoint-xl: 80rem;
3227+
--breakpoint-3xl: 1600px;
3228+
--breakpoint-sm: 40em;
3229+
--breakpoint-2xl: 96rem;
3230+
--breakpoint-xs: 30px;
3231+
--breakpoint-md: 48em;
3232+
}
3233+
@tailwind utilities;
3234+
`,
3235+
['container'],
3236+
),
3237+
).toMatchInlineSnapshot(`
3238+
".container {
3239+
width: 100%;
3240+
}
3241+
3242+
@media (width >= 40em) {
3243+
.container {
3244+
max-width: 40em;
3245+
}
3246+
}
3247+
3248+
@media (width >= 48em) {
3249+
.container {
3250+
max-width: 48em;
3251+
}
3252+
}
3253+
3254+
@media (width >= 30px) {
3255+
.container {
3256+
max-width: 30px;
3257+
}
3258+
}
3259+
3260+
@media (width >= 1600px) {
3261+
.container {
3262+
max-width: 1600px;
3263+
}
3264+
}
3265+
3266+
@media (width >= 64rem) {
3267+
.container {
3268+
max-width: 64rem;
3269+
}
3270+
}
3271+
3272+
@media (width >= 80rem) {
3273+
.container {
3274+
max-width: 80rem;
3275+
}
3276+
}
3277+
3278+
@media (width >= 96rem) {
3279+
.container {
3280+
max-width: 96rem;
3281+
}
3282+
}"
3283+
`)
3284+
})
3285+
3286+
test('custom `@utility container` always follow the core utility ', async () => {
3287+
expect(
3288+
await compileCss(
3289+
css`
3290+
@theme {
3291+
--breakpoint-sm: 40rem;
3292+
--breakpoint-md: 48rem;
3293+
--breakpoint-lg: 64rem;
3294+
--breakpoint-xl: 80rem;
3295+
--breakpoint-2xl: 96rem;
3296+
}
3297+
@tailwind utilities;
3298+
3299+
@utility container {
3300+
margin-inline: auto;
3301+
padding-inline: 1rem;
3302+
3303+
@media (width >= theme(--breakpoint-sm)) {
3304+
padding-inline: 2rem;
3305+
}
3306+
}
3307+
`,
3308+
['w-1/2', 'container', 'max-w-[var(--breakpoint-sm)]'],
3309+
),
3310+
).toMatchInlineSnapshot(`
3311+
":root {
3312+
--breakpoint-sm: 40rem;
3313+
--breakpoint-md: 48rem;
3314+
--breakpoint-lg: 64rem;
3315+
--breakpoint-xl: 80rem;
3316+
--breakpoint-2xl: 96rem;
3317+
}
3318+
3319+
.container {
3320+
width: 100%;
3321+
}
3322+
3323+
@media (width >= 40rem) {
3324+
.container {
3325+
max-width: 40rem;
3326+
}
3327+
}
3328+
3329+
@media (width >= 48rem) {
3330+
.container {
3331+
max-width: 48rem;
3332+
}
3333+
}
3334+
3335+
@media (width >= 64rem) {
3336+
.container {
3337+
max-width: 64rem;
3338+
}
3339+
}
3340+
3341+
@media (width >= 80rem) {
3342+
.container {
3343+
max-width: 80rem;
3344+
}
3345+
}
3346+
3347+
@media (width >= 96rem) {
3348+
.container {
3349+
max-width: 96rem;
3350+
}
3351+
}
3352+
3353+
.container {
3354+
margin-inline: auto;
3355+
padding-inline: 1rem;
3356+
}
3357+
3358+
@media (width >= 40rem) {
3359+
.container {
3360+
padding-inline: 2rem;
3361+
}
3362+
}
3363+
3364+
.w-1\\/2 {
3365+
width: 50%;
3366+
}
3367+
3368+
.max-w-\\[var\\(--breakpoint-sm\\)\\] {
3369+
max-width: var(--breakpoint-sm);
3370+
}"
3371+
`)
3372+
})
3373+
})
3374+
31513375
test('flex', async () => {
31523376
expect(
31533377
await run([
@@ -16680,7 +16904,7 @@ describe('spacing utilities', () => {
1668016904
`)
1668116905
})
1668216906

16683-
test('only multiples of 0.25 with no trailing zeroes are supported with the spacing multipler', async () => {
16907+
test('only multiples of 0.25 with no trailing zeroes are supported with the spacing multiplier', async () => {
1668416908
let { build } = await compile(css`
1668516909
@theme {
1668616910
--spacing: 4px;

packages/tailwindcss/src/utilities.ts

+13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { atRoot, atRule, decl, styleRule, type AstNode } from './ast'
22
import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate'
33
import type { Theme, ThemeKey } from './theme'
4+
import { compareBreakpoints } from './utils/compare-breakpoints'
45
import { DefaultMap } from './utils/default-map'
56
import {
67
inferDataType,
@@ -897,6 +898,18 @@ export function createUtilities(theme: Theme) {
897898
})
898899
}
899900

901+
utilities.static('container', () => {
902+
let breakpoints = [...theme.namespace('--breakpoint').values()]
903+
breakpoints.sort((a, z) => compareBreakpoints(a, z, 'asc'))
904+
905+
let decls: AstNode[] = [decl('--tw-sort', '--tw-container-component'), decl('width', '100%')]
906+
for (let breakpoint of breakpoints) {
907+
decls.push(atRule('@media', `(min-width: ${breakpoint})`, [decl('max-width', breakpoint)]))
908+
}
909+
910+
return decls
911+
})
912+
900913
/**
901914
* @css `flex`
902915
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export function compareBreakpoints(a: string, z: string, direction: 'asc' | 'desc') {
2+
if (a === z) return 0
3+
4+
// Assumption: when a `(` exists, we are dealing with a CSS function.
5+
//
6+
// E.g.: `calc(100% - 1rem)`
7+
let aIsCssFunction = a.indexOf('(')
8+
let zIsCssFunction = z.indexOf('(')
9+
10+
let aBucket =
11+
aIsCssFunction === -1
12+
? // No CSS function found, bucket by unit instead
13+
a.replace(/[\d.]+/g, '')
14+
: // CSS function found, bucket by function name
15+
a.slice(0, aIsCssFunction)
16+
17+
let zBucket =
18+
zIsCssFunction === -1
19+
? // No CSS function found, bucket by unit
20+
z.replace(/[\d.]+/g, '')
21+
: // CSS function found, bucket by function name
22+
z.slice(0, zIsCssFunction)
23+
24+
let order =
25+
// Compare by bucket name
26+
(aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) ||
27+
// If bucket names are the same, compare by value
28+
(direction === 'asc' ? parseInt(a) - parseInt(z) : parseInt(z) - parseInt(a))
29+
30+
// If the groups are the same, and the contents are not numbers, the
31+
// `order` will result in `NaN`. In this case, we want to make sorting
32+
// stable by falling back to a string comparison.
33+
//
34+
// This can happen when using CSS functions such as `calc`.
35+
//
36+
// E.g.:
37+
//
38+
// - `min-[calc(100%-1rem)]` and `min-[calc(100%-2rem)]`
39+
// - `@[calc(100%-1rem)]` and `@[calc(100%-2rem)]`
40+
//
41+
// In this scenario, we want to alphabetically sort `calc(100%-1rem)` and
42+
// `calc(100%-2rem)` to make it deterministic.
43+
if (Number.isNaN(order)) {
44+
return a < z ? -1 : 1
45+
}
46+
47+
return order
48+
}

0 commit comments

Comments
 (0)