Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0303327
add details-content variant
dcastil Apr 5, 2025
24ffa43
Add `items-baseline-last` and `self-baseline-last` utilities
dcastil Apr 5, 2025
1d7aadf
Add safe alignment utilities
dcastil Apr 5, 2025
7b98a15
Add `wrap-anywhere`, `wrap-break-word`, and `wrap-normal` utilities
dcastil Apr 5, 2025
1f44511
Add `text-shadow-*` utilities
dcastil Apr 5, 2025
bc32b83
Add `mask-clip`, `mask-composite`, `mask-mode`, `mask-origin`, `mask-…
dcastil Apr 6, 2025
e30a5b2
Add `mask-position` utilities
dcastil Apr 6, 2025
693eb0e
improve performance of validators which allow multiple labels
dcastil Apr 6, 2025
a204250
Add `mask-size` utilities
dcastil Apr 6, 2025
8baefda
Add `mask-image` utilities
dcastil Apr 6, 2025
b757cd8
fix percentage label belonging to bg-position instead of bg-size
dcastil Apr 6, 2025
d7fa23b
fix conflict declaration between border-width groups and between bord…
dcastil Apr 6, 2025
5ab81b3
compress class groups a bit
dcastil Apr 6, 2025
fedad41
attempt to reduce bundle size impact of mask-image class groups
dcastil Apr 6, 2025
268f096
Revert "attempt to reduce bundle size impact of mask-image class groups"
dcastil Apr 6, 2025
1b36b3b
Add `shadow-*/<alpha>`, `inset-shadow-*/<alpha>`, `drop-shadow-*/<alp…
dcastil Apr 6, 2025
3722903
Add `drop-shadow-<color>` utilities
dcastil Apr 6, 2025
d4ff609
Deprecate `bg-{left,right}-{top,bottom}` in favor of `bg-{top,bottom}…
dcastil Apr 6, 2025
2afc3b6
add Tailwind CSS v4.1 support info to docs
dcastil Apr 6, 2025
ff44d9a
restore missing opacity JSDoc
dcastil Apr 6, 2025
442502d
improve stacked modifiers feature explanation
dcastil Apr 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'
```

- Supports Tailwind v4.0 (if you use Tailwind v3, use [tailwind-merge v2.6.0](https://github.com/dcastil/tailwind-merge/tree/v2.6.0))
- Supports Tailwind v4.0 up to v4.1 (if you use Tailwind v3, use [tailwind-merge v2.6.0](https://github.com/dcastil/tailwind-merge/tree/v2.6.0))
- Works in all modern browsers and maintained Node versions
- Fully typed
- [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge)
Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ twMerge('hover:p-2 hover:p-4') // → 'hover:p-4'
twMerge('hover:focus:p-2 focus:hover:p-4') // → 'focus:hover:p-4'
```

The order of standard modifiers does not matter for tailwind-merge.
tailwind-merge knows when the order of standard modifiers matters and when not and resolves conflicts accordingly.

### Supports arbitrary values

Expand Down
251 changes: 204 additions & 47 deletions src/lib/default-config.ts

Large diffs are not rendered by default.

63 changes: 61 additions & 2 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,18 @@ interface ConfigGroupsPart<ClassGroupIds extends string, ThemeGroupIds extends s
/**
* Conflicting classes across groups.
*
* The key is ID of class group which creates conflict, values are IDs of class groups which receive a conflict.
* A class group ID is the key of a class group in classGroups object.
* The key is the ID of a class group which creates a conflict, values are IDs of class groups which receive a conflict. That means if a class from from the key ID is present, all preceding classes from the values are removed.
*
* A class group ID is the key of a class group in the classGroups object.
*
* @example { gap: ['gap-x', 'gap-y'] }
*/
conflictingClassGroups: NoInfer<Partial<Record<ClassGroupIds, readonly ClassGroupIds[]>>>
/**
* Postfix modifiers conflicting with other class groups.
*
* A class group ID is the key of a class group in classGroups object.
*
* @example { 'font-size': ['leading'] }
*/
conflictingClassGroupModifiers: NoInfer<
Expand Down Expand Up @@ -196,6 +199,7 @@ export type DefaultThemeGroupIds =
| 'shadow'
| 'spacing'
| 'text'
| 'text-shadow'
| 'tracking'

/**
Expand Down Expand Up @@ -285,6 +289,7 @@ export type DefaultClassGroupIds =
| 'divide-y-reverse'
| 'divide-y'
| 'drop-shadow'
| 'drop-shadow-color'
| 'duration'
| 'ease'
| 'end'
Expand Down Expand Up @@ -345,6 +350,57 @@ export type DefaultClassGroupIds =
| 'list-style-position'
| 'list-style-type'
| 'm'
| 'mask-clip'
| 'mask-composite'
| 'mask-image-b-from-color'
| 'mask-image-b-from-pos'
| 'mask-image-b-to-color'
| 'mask-image-b-to-pos'
| 'mask-image-conic-from-color'
| 'mask-image-conic-from-pos'
| 'mask-image-conic-pos'
| 'mask-image-conic-to-color'
| 'mask-image-conic-to-pos'
| 'mask-image-l-from-color'
| 'mask-image-l-from-pos'
| 'mask-image-l-to-color'
| 'mask-image-l-to-pos'
| 'mask-image-linear-from-color'
| 'mask-image-linear-from-pos'
| 'mask-image-linear-pos'
| 'mask-image-linear-to-color'
| 'mask-image-linear-to-pos'
| 'mask-image-r-from-color'
| 'mask-image-r-from-pos'
| 'mask-image-r-to-color'
| 'mask-image-r-to-pos'
| 'mask-image-radial-from-color'
| 'mask-image-radial-from-pos'
| 'mask-image-radial-pos'
| 'mask-image-radial-shape'
| 'mask-image-radial-size'
| 'mask-image-radial-to-color'
| 'mask-image-radial-to-pos'
| 'mask-image-radial'
| 'mask-image-t-from-color'
| 'mask-image-t-from-pos'
| 'mask-image-t-to-color'
| 'mask-image-t-to-pos'
| 'mask-image-x-from-color'
| 'mask-image-x-from-pos'
| 'mask-image-x-to-color'
| 'mask-image-x-to-pos'
| 'mask-image-y-from-color'
| 'mask-image-y-from-pos'
| 'mask-image-y-to-color'
| 'mask-image-y-to-pos'
| 'mask-image'
| 'mask-mode'
| 'mask-origin'
| 'mask-position'
| 'mask-repeat'
| 'mask-size'
| 'mask-type'
| 'max-h'
| 'max-w'
| 'mb'
Expand Down Expand Up @@ -472,6 +528,8 @@ export type DefaultClassGroupIds =
| 'text-decoration-thickness'
| 'text-decoration'
| 'text-overflow'
| 'text-shadow'
| 'text-shadow-color'
| 'text-transform'
| 'text-wrap'
| 'top'
Expand All @@ -496,6 +554,7 @@ export type DefaultClassGroupIds =
| 'w'
| 'whitespace'
| 'will-change'
| 'wrap'
| 'z'

export type AnyClassGroupIds = string
Expand Down
17 changes: 7 additions & 10 deletions src/lib/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ const imageRegex =

export const isFraction = (value: string) => fractionRegex.test(value)

export const isNumber = (value: string) => Boolean(value) && !Number.isNaN(Number(value))
export const isNumber = (value: string) => !!value && !Number.isNaN(Number(value))

export const isInteger = (value: string) => Boolean(value) && Number.isInteger(Number(value))
export const isInteger = (value: string) => !!value && Number.isInteger(Number(value))

export const isPercent = (value: string) => value.endsWith('%') && isNumber(value.slice(0, -1))

Expand Down Expand Up @@ -52,7 +52,8 @@ export const isArbitraryPosition = (value: string) =>

export const isArbitraryImage = (value: string) => getIsArbitraryValue(value, isLabelImage, isImage)

export const isArbitraryShadow = (value: string) => getIsArbitraryValue(value, isNever, isShadow)
export const isArbitraryShadow = (value: string) =>
getIsArbitraryValue(value, isLabelShadow, isShadow)

export const isArbitraryVariable = (value: string) => arbitraryVariableRegex.test(value)

Expand Down Expand Up @@ -112,15 +113,11 @@ const getIsArbitraryVariable = (

// Labels

const isLabelPosition = (label: string) => label === 'position'
const isLabelPosition = (label: string) => label === 'position' || label === 'percentage'

const imageLabels = new Set(['image', 'url'])
const isLabelImage = (label: string) => label === 'image' || label === 'url'

const isLabelImage = (label: string) => imageLabels.has(label)

const sizeLabels = new Set(['length', 'size', 'percentage'])

const isLabelSize = (label: string) => sizeLabels.has(label)
const isLabelSize = (label: string) => label === 'length' || label === 'size' || label === 'bg-size'

const isLabelLength = (label: string) => label === 'length'

Expand Down
6 changes: 3 additions & 3 deletions tests/arbitrary-values.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ test('handles ambiguous arbitrary values correctly', () => {
expect(twMerge('text-2xl text-[calc(theme(fontSize.4xl)/1.125)]')).toBe(
'text-[calc(theme(fontSize.4xl)/1.125)]',
)
expect(twMerge('bg-cover bg-[percentage:30%] bg-[length:200px_100px]')).toBe(
'bg-[length:200px_100px]',
)
expect(
twMerge('bg-cover bg-[percentage:30%] bg-[size:200px_100px] bg-[length:200px_100px]'),
).toBe('bg-[percentage:30%] bg-[length:200px_100px]')
expect(
twMerge(
'bg-none bg-[url(.)] bg-[image:.] bg-[url:.] bg-[linear-gradient(.)] bg-linear-to-r',
Expand Down
66 changes: 64 additions & 2 deletions tests/class-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ test('class map has correct class groups at first part', () => {
'divide-y',
'divide-y-reverse',
],
drop: ['drop-shadow'],
drop: ['drop-shadow', 'drop-shadow-color'],
duration: ['duration'],
ease: ['ease'],
end: ['end'],
Expand Down Expand Up @@ -150,6 +150,59 @@ test('class map has correct class groups at first part', () => {
list: ['display', 'list-image', 'list-style-position', 'list-style-type'],
lowercase: ['text-transform'],
m: ['m'],
mask: [
'mask-clip',
'mask-composite',
'mask-image',
'mask-image-b-from-color',
'mask-image-b-from-pos',
'mask-image-b-to-color',
'mask-image-b-to-pos',
'mask-image-conic-from-color',
'mask-image-conic-from-pos',
'mask-image-conic-pos',
'mask-image-conic-to-color',
'mask-image-conic-to-pos',
'mask-image-l-from-color',
'mask-image-l-from-pos',
'mask-image-l-to-color',
'mask-image-l-to-pos',
'mask-image-linear-from-color',
'mask-image-linear-from-pos',
'mask-image-linear-pos',
'mask-image-linear-to-color',
'mask-image-linear-to-pos',
'mask-image-r-from-color',
'mask-image-r-from-pos',
'mask-image-r-to-color',
'mask-image-r-to-pos',
'mask-image-radial',
'mask-image-radial-from-color',
'mask-image-radial-from-pos',
'mask-image-radial-pos',
'mask-image-radial-shape',
'mask-image-radial-size',
'mask-image-radial-to-color',
'mask-image-radial-to-pos',
'mask-image-t-from-color',
'mask-image-t-from-pos',
'mask-image-t-to-color',
'mask-image-t-to-pos',
'mask-image-x-from-color',
'mask-image-x-from-pos',
'mask-image-x-to-color',
'mask-image-x-to-pos',
'mask-image-y-from-color',
'mask-image-y-from-pos',
'mask-image-y-to-color',
'mask-image-y-to-pos',
'mask-mode',
'mask-origin',
'mask-position',
'mask-repeat',
'mask-size',
'mask-type',
],
max: ['max-h', 'max-w'],
mb: ['mb'],
me: ['me'],
Expand Down Expand Up @@ -254,7 +307,15 @@ test('class map has correct class groups at first part', () => {
subpixel: ['font-smoothing'],
table: ['display', 'table-layout'],
tabular: ['fvn-spacing'],
text: ['font-size', 'text-alignment', 'text-color', 'text-overflow', 'text-wrap'],
text: [
'font-size',
'text-alignment',
'text-color',
'text-overflow',
'text-shadow',
'text-shadow-color',
'text-wrap',
],
to: ['gradient-to', 'gradient-to-pos'],
top: ['top'],
touch: ['touch', 'touch-pz', 'touch-x', 'touch-y'],
Expand All @@ -270,6 +331,7 @@ test('class map has correct class groups at first part', () => {
w: ['w'],
whitespace: ['whitespace'],
will: ['will-change'],
wrap: ['wrap'],
z: ['z'],
})
})
Expand Down
74 changes: 74 additions & 0 deletions tests/tailwind-css-versions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,77 @@ test('supports Tailwind CSS v4.0 features', () => {
'via-red-500 via-(length:--mobile-header-gradient)',
)
})

test('supports Tailwind CSS v4.1 features', () => {
expect(twMerge('items-baseline items-baseline-last')).toBe('items-baseline-last')
expect(twMerge('self-baseline self-baseline-last')).toBe('self-baseline-last')
expect(twMerge('place-content-center place-content-end-safe place-content-center-safe')).toBe(
'place-content-center-safe',
)
expect(twMerge('items-center-safe items-baseline items-end-safe')).toBe('items-end-safe')
expect(twMerge('wrap-break-word wrap-normal wrap-anywhere')).toBe('wrap-anywhere')
expect(twMerge('text-shadow-none text-shadow-2xl')).toBe('text-shadow-2xl')
expect(
twMerge(
'text-shadow-none text-shadow-md text-shadow-red text-shadow-red-500 shadow-red shadow-3xs',
),
).toBe('text-shadow-md text-shadow-red-500 shadow-red shadow-3xs')
expect(twMerge('mask-add mask-subtract')).toBe('mask-subtract')
expect(
twMerge(
// mask-image
'mask-(--foo) mask-[foo] mask-none',
// mask-image-linear-pos
'mask-linear-1 mask-linear-2',
// mask-image-linear-from-pos
'mask-linear-from-[position:test] mask-linear-from-3',
// mask-image-linear-to-pos
'mask-linear-to-[position:test] mask-linear-to-3',
// mask-image-linear-from-color
'mask-linear-from-color-red mask-linear-from-color-3',
// mask-image-linear-to-color
'mask-linear-to-color-red mask-linear-to-color-3',
// mask-image-t-from-pos
'mask-t-from-[position:test] mask-t-from-3',
// mask-image-t-to-pos
'mask-t-to-[position:test] mask-t-to-3',
// mask-image-t-from-color
'mask-t-from-color-red mask-t-from-color-3',
// mask-image-radial
'mask-radial-(--test) mask-radial-[test]',
// mask-image-radial-from-pos
'mask-radial-from-[position:test] mask-radial-from-3',
// mask-image-radial-to-pos
'mask-radial-to-[position:test] mask-radial-to-3',
// mask-image-radial-from-color
'mask-radial-from-color-red mask-radial-from-color-3',
),
).toBe(
'mask-none mask-linear-2 mask-linear-from-3 mask-linear-to-3 mask-linear-from-color-3 mask-linear-to-color-3 mask-t-from-3 mask-t-to-3 mask-t-from-color-3 mask-radial-[test] mask-radial-from-3 mask-radial-to-3 mask-radial-from-color-3',
)
expect(
twMerge(
// mask-image
'mask-(--something) mask-[something]',
// mask-position
'mask-top-left mask-center mask-(position:--var) mask-[position:1px_1px] mask-position-(--var) mask-position-[1px_1px]',
),
).toBe('mask-[something] mask-position-[1px_1px]')
expect(
twMerge(
// mask-image
'mask-(--something) mask-[something]',
// mask-size
'mask-auto mask-[size:foo] mask-(size:--foo) mask-size-[foo] mask-size-(--foo) mask-cover mask-contain',
),
).toBe('mask-[something] mask-contain')
expect(twMerge('mask-type-luminance mask-type-alpha')).toBe('mask-type-alpha')
expect(twMerge('shadow-md shadow-lg/25 text-shadow-md text-shadow-lg/25')).toBe(
'shadow-lg/25 text-shadow-lg/25',
)
expect(
twMerge('drop-shadow-some-color drop-shadow-[#123456] drop-shadow-lg drop-shadow-[10px_0]'),
).toBe('drop-shadow-[#123456] drop-shadow-[10px_0]')
expect(twMerge('drop-shadow-[#123456] drop-shadow-some-color')).toBe('drop-shadow-some-color')
expect(twMerge('drop-shadow-2xl drop-shadow-[shadow:foo]')).toBe('drop-shadow-[shadow:foo]')
})
6 changes: 4 additions & 2 deletions tests/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ test('isArbitraryNumber', () => {
test('isArbitraryPosition', () => {
expect(isArbitraryPosition('[position:2px]')).toBe(true)
expect(isArbitraryPosition('[position:bla]')).toBe(true)
expect(isArbitraryPosition('[percentage:bla]')).toBe(true)

expect(isArbitraryPosition('[2px]')).toBe(false)
expect(isArbitraryPosition('[bla]')).toBe(false)
Expand All @@ -120,11 +121,11 @@ test('isArbitrarySize', () => {
expect(isArbitrarySize('[size:2px]')).toBe(true)
expect(isArbitrarySize('[size:bla]')).toBe(true)
expect(isArbitrarySize('[length:bla]')).toBe(true)
expect(isArbitrarySize('[percentage:bla]')).toBe(true)

expect(isArbitrarySize('[2px]')).toBe(false)
expect(isArbitrarySize('[bla]')).toBe(false)
expect(isArbitrarySize('size:2px')).toBe(false)
expect(isArbitrarySize('[percentage:bla]')).toBe(false)
})

test('isArbitraryValue', () => {
Expand Down Expand Up @@ -187,6 +188,7 @@ test('isArbitraryVariablePosition', () => {
expect(isArbitraryVariablePosition('(other:test)')).toBe(false)
expect(isArbitraryVariablePosition('(test)')).toBe(false)
expect(isArbitraryVariablePosition('position:test')).toBe(false)
expect(isArbitraryVariablePosition('percentage:test')).toBe(false)
})

test('isArbitraryVariableShadow', () => {
Expand All @@ -200,11 +202,11 @@ test('isArbitraryVariableShadow', () => {
test('isArbitraryVariableSize', () => {
expect(isArbitraryVariableSize('(size:test)')).toBe(true)
expect(isArbitraryVariableSize('(length:test)')).toBe(true)
expect(isArbitraryVariableSize('(percentage:test)')).toBe(true)

expect(isArbitraryVariableSize('(other:test)')).toBe(false)
expect(isArbitraryVariableSize('(test)')).toBe(false)
expect(isArbitraryVariableSize('size:test')).toBe(false)
expect(isArbitraryVariableSize('(percentage:test)')).toBe(false)
})

test('isFraction', () => {
Expand Down
Loading