├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── config.yml ├── SECURITY.md ├── actions │ └── metrics-report │ │ ├── .gitignore │ │ ├── action.yml │ │ ├── package.json │ │ ├── src │ │ ├── get-package-size.mjs │ │ ├── get-size-metrics-report-content.mjs │ │ ├── main.mjs │ │ ├── set-comment.mjs │ │ └── utils │ │ │ ├── git.mjs │ │ │ ├── github.mjs │ │ │ ├── html.mjs │ │ │ └── path.mjs │ │ └── yarn.lock ├── funding.yml ├── release-drafter.yml ├── renovate.json └── workflows │ ├── benchmark.yml │ ├── codeql-analysis.yml │ ├── comment-released-prs-and-issues.yml │ ├── draft-release.yml │ ├── label.yml │ ├── metrics-report.yml │ ├── npm-publish-dev.yml │ ├── npm-publish.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── assets └── logo.svg ├── docs ├── README.md ├── api-reference.md ├── changelog │ ├── v0-changelog.md │ ├── v0-to-v1-migration.md │ ├── v1-changelog.md │ ├── v1-to-v2-migration.md │ ├── v2-changelog.md │ ├── v2-to-v3-migration.md │ └── v3-changelog.md ├── configuration.md ├── contributing.md ├── features.md ├── limitations.md ├── recipes.md ├── similar-packages.md ├── versioning.md ├── what-is-it-for.md ├── when-and-how-to-use-it.md └── writing-plugins.md ├── eslint.config.mjs ├── package.json ├── scripts ├── rollup.config.mjs ├── test-built-package-exports.cjs ├── test-built-package-exports.mjs ├── update-readme.mjs └── vitest.config.mts ├── src ├── index.ts └── lib │ ├── class-group-utils.ts │ ├── config-utils.ts │ ├── create-tailwind-merge.ts │ ├── default-config.ts │ ├── extend-tailwind-merge.ts │ ├── from-theme.ts │ ├── lru-cache.ts │ ├── merge-classlist.ts │ ├── merge-configs.ts │ ├── parse-class-name.ts │ ├── sort-modifiers.ts │ ├── tw-join.ts │ ├── tw-merge.ts │ ├── types.ts │ └── validators.ts ├── tests ├── arbitrary-properties.test.ts ├── arbitrary-values.test.ts ├── arbitrary-variants.test.ts ├── class-group-conflicts.test.ts ├── class-map.test.ts ├── colors.test.ts ├── conflicts-across-class-groups.test.ts ├── content-utilities.test.ts ├── create-tailwind-merge.test.ts ├── default-config.test.ts ├── docs-examples.test.ts ├── experimental-parse-class-name.test.ts ├── extend-tailwind-merge.test.ts ├── important-modifier.test.ts ├── lazy-initialization.test.ts ├── merge-configs.test.ts ├── modifiers.test.ts ├── negative-values.test.ts ├── non-conflicting-classes.test.ts ├── non-tailwind-classes.test.ts ├── per-side-border-colors.test.ts ├── prefixes.test.ts ├── pseudo-variants.test.ts ├── public-api.test.ts ├── standalone-classes.test.ts ├── tailwind-css-versions.test.ts ├── theme.test.ts ├── tsconfig.json ├── tw-join.test.ts ├── tw-merge-benchmark-data.json ├── tw-merge.benchmark.ts ├── tw-merge.test.ts ├── type-generics.test.ts ├── validators.test.ts └── wonky-inputs.test.ts ├── tsconfig.json └── yarn.lock /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # tailwind-merge Community Guidelines 2 | 3 | The following community guidelines are based on [The Ruby Community Conduct Guidelines](https://www.ruby-lang.org/en/conduct) which are also used by [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss/blob/master/.github/CODE_OF_CONDUCT.md). 4 | 5 | This document provides community guidelines for a safe, respectful, productive, and collaborative place for any person who is willing to contribute to the tailwind-merge project. It applies to all “collaborative space”, which is defined as community communications channels (such as issues, discussions, pull requests, commit comments, etc.). 6 | 7 | - Participants will be tolerant of opposing views. 8 | - Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks. 9 | - When interpreting the words and actions of others, participants should always assume good intentions. 10 | - Behaviour which can be reasonably considered harassment will not be tolerated. 11 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome and will be fully credited. 4 | 5 | Please take a moment to review this document before creating an issue or pull request. It is based on the [Tiptap contributing guidelines](https://github.com/ueberdosis/tiptap/blob/main/CONTRIBUTING.md). 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code held within. They make the code freely available in the hope that it will be of use to other developers. It would be extremely unfair for them to suffer abuse or anger for their hard work. 10 | 11 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that developers are civilized and selfless people. 12 | 13 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 14 | 15 | See [CODE OF CONDUCT](CODE_OF_CONDUCT.md) for more info. 16 | 17 | ## Viability 18 | 19 | When requesting or submitting new features, first consider whether it might be useful to others. Open source projects are used by many developers, who may have entirely different needs to your own. Think about whether or not your feature is likely to be used by other users of the project. 20 | 21 | ## Procedure 22 | 23 | Before filing an issue: 24 | 25 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 26 | - Check to make sure your feature suggestion isn't already present within the project. 27 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 28 | - Check the pull requests tab to ensure that the feature isn't already in progress. 29 | 30 | Before submitting a pull request: 31 | 32 | - Check the codebase to ensure that your feature doesn't already exist. 33 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 34 | 35 | ## Requirements 36 | 37 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 38 | 39 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 40 | 41 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 42 | 43 | ## Development 44 | 45 | You will need [Node.js](https://nodejs.org) and [yarn](https://classic.yarnpkg.com) installed on your machine. I recommend running tests in watch mode while you work on the code. Then the correct subset of tests is being run as you modify source code or the tests itself. 46 | 47 | ```sh 48 | # Install dependencies 49 | $ yarn 50 | # Run tests 51 | $ yarn test --watch 52 | ``` 53 | 54 | Happy coding! 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report that something isn't working as expected in tailwind-merge 4 | --- 5 | 6 | ### Describe the bug 7 | 8 | 9 | 10 | ### To Reproduce 11 | 12 | 13 | 14 | ### Expected behavior 15 | 16 | 17 | 18 | ### Environment 19 | 20 | 21 | 22 | - tailwind-merge version: [e.g. 1.13.1] 23 | 24 | ### Additional context 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/dcastil/tailwind-merge/discussions/new?category=help 5 | about: If you can't get something to work the way you expect, open a question in the discussion forums. 6 | - name: Feature Request 7 | url: https://github.com/dcastil/tailwind-merge/discussions/new?category=ideas 8 | about: Suggest any ideas you have using the discussion forum. 9 | - name: Start discussion 10 | url: https://github.com/dcastil/tailwind-merge/discussions 11 | about: Anything else on your mind? Check out the discussions forum. 12 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Security updates are available for the two latest major versions. 6 | 7 | In the event of a security vulnerability in tailwind-merge, a patch release with a fix will be made to all affected latest major versions. I.e. if the two latest major versions of tailwind-merge would be `v9.3.4` and `v8.10.0` and a security vulnerability would get discovered which affected all versions from `v6.0.0` to `v9.3.4`, then at least the releases `v9.3.5` and `v8.10.1` would be made to fix the security vulnerability. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Please report vulnerabilities privately via GitHub at https://github.com/dcastil/tailwind-merge/security. 12 | 13 | In case it is not possible to report a vulnerability via GitHub, you can send me an email to metro_comical_03@icloud.com. However, I might change or disable this email address at any time depending on how much spam I get through it. 14 | 15 | You can expect an answer from me within 24 hours most of the time. However, if I'm travelling and don't have good reception, it could take up to a few days. Usually I set my GitHub status to busy when I expect to be unresponsive for more than a day. 16 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/.gitignore: -------------------------------------------------------------------------------- 1 | temp 2 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/action.yml: -------------------------------------------------------------------------------- 1 | name: 'metrics-report-action' 2 | author: 'Dany Castillo' 3 | description: 'Posts a comment with a report about changes in important metrics related to tailwind-merge' 4 | inputs: 5 | github_token: 6 | description: 'Github token of the repository (automatically created by Github)' 7 | default: '${{ github.token }}' 8 | required: false 9 | runs: 10 | using: 'node20' 11 | main: 'src/main.mjs' 12 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "metrics-report-action", 3 | "version": "0.1.0", 4 | "private": true, 5 | "author": "Dany Castillo", 6 | "scripts": { 7 | "start": "node src/main.mjs" 8 | }, 9 | "dependencies": { 10 | "@actions/core": "^1.11.1", 11 | "@actions/exec": "^1.1.1", 12 | "@actions/github": "^6.0.1", 13 | "@octokit/types": "^14.1.0", 14 | "@octokit/webhooks-definitions": "^3.67.3", 15 | "esbuild": "^0.25.5", 16 | "rollup": "^4.41.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/get-package-size.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import fs from 'fs/promises' 4 | import path from 'path' 5 | import { promisify } from 'util' 6 | import zlib from 'zlib' 7 | 8 | import core from '@actions/core' 9 | import { exec } from '@actions/exec' 10 | import { transform } from 'esbuild' 11 | import { rollup } from 'rollup' 12 | 13 | import { actionRootPath, repoRootPath } from './utils/path.mjs' 14 | 15 | /** 16 | * @typedef {object} GetPackageSizeOptions 17 | * @property {boolean=} shouldOmitFailures 18 | */ 19 | 20 | /** 21 | * @param {GetPackageSizeOptions=} options 22 | */ 23 | export async function getPackageSize(options = {}) { 24 | await buildPackage() 25 | 26 | return getEntryPointSizes(options) 27 | } 28 | 29 | async function buildPackage() { 30 | core.info('Installing dependencies') 31 | await exec('yarn install --frozen-lockfile', [], { cwd: repoRootPath }) 32 | 33 | core.info('Building package') 34 | await exec('yarn build', [], { cwd: repoRootPath }) 35 | } 36 | 37 | /** 38 | * @typedef {object} EntryPointSize 39 | * @property {BundleSize} bundleSize 40 | * @property {BundleSize[]=} singleExportSizes 41 | */ 42 | 43 | /** 44 | * @param {GetPackageSizeOptions} param0 45 | * @returns {Promise} 46 | */ 47 | async function getEntryPointSizes({ shouldOmitFailures }) { 48 | core.info('Getting entry point configs') 49 | const entryPointConfigs = await getEntryPointConfigs() 50 | 51 | core.info('Getting bundle sizes') 52 | 53 | const maybeEntryPointSizes = await Promise.all( 54 | entryPointConfigs.map(async (entryPointConfig, entryPointIndex) => { 55 | const entryPointBundlePath = path.resolve(repoRootPath, entryPointConfig.bundlePath) 56 | 57 | const bundle = await getBundle(entryPointConfig, entryPointBundlePath).catch( 58 | (error) => { 59 | if (shouldOmitFailures) { 60 | core.info( 61 | `Failed to get bundle for ${entryPointConfig.entryPoint}: ${error.message}`, 62 | ) 63 | return 64 | } 65 | 66 | throw error 67 | }, 68 | ) 69 | 70 | if (!bundle) { 71 | return 72 | } 73 | 74 | const [bundleSize, singleExportSizes] = await Promise.all([ 75 | getBundleSize(entryPointConfig.entryPoint, bundle), 76 | getSingleExportBundleSizes( 77 | entryPointConfig, 78 | entryPointIndex, 79 | entryPointBundlePath, 80 | bundle, 81 | ), 82 | ]) 83 | 84 | return { 85 | bundleSize, 86 | singleExportSizes, 87 | } 88 | }), 89 | ) 90 | 91 | /** @type {any} */ 92 | const entryPointSizes = maybeEntryPointSizes.filter((bundleSize) => bundleSize !== undefined) 93 | 94 | return entryPointSizes 95 | } 96 | 97 | /** 98 | * @typedef {object} EntryPointConfiguration 99 | * @property {string} entryPoint 100 | * @property {string} bundlePath 101 | * @property {'esm' | 'cjs'} format 102 | */ 103 | 104 | /** 105 | * @returns {Promise} 106 | */ 107 | async function getEntryPointConfigs() { 108 | const pkg = (await import('../../../../package.json', { assert: { type: 'json' } })).default 109 | 110 | return Object.entries(pkg.exports).flatMap(([relativeEntryPointPath, bundleObject]) => { 111 | const entryPointPath = path.join('tailwind-merge', relativeEntryPointPath) 112 | 113 | /** @type {EntryPointConfiguration[]} */ 114 | const entryPointConfigs = [] 115 | 116 | if (bundleObject.import) { 117 | entryPointConfigs.push({ 118 | entryPoint: entryPointPath + ' esm', 119 | bundlePath: bundleObject.import, 120 | format: 'esm', 121 | }) 122 | } 123 | 124 | if (bundleObject.require) { 125 | entryPointConfigs.push({ 126 | entryPoint: entryPointPath + ' cjs', 127 | bundlePath: bundleObject.require, 128 | format: 'cjs', 129 | }) 130 | } 131 | 132 | return entryPointConfigs 133 | }) 134 | } 135 | 136 | /** 137 | * @param {EntryPointConfiguration} entryPointConfig 138 | * @param {string} entryPoint 139 | */ 140 | async function getBundle(entryPointConfig, entryPoint) { 141 | const rollupBuild = await rollup({ input: entryPoint }) 142 | let rollupOutput 143 | 144 | try { 145 | rollupOutput = await rollupBuild.generate({ 146 | format: entryPointConfig.format, 147 | }) 148 | } catch (error) { 149 | await rollupBuild.close() 150 | throw error 151 | } 152 | 153 | await rollupBuild.close() 154 | 155 | if (rollupOutput.output.length !== 1) { 156 | throw Error(`Expected a single output chunk for bundle ${entryPoint}`) 157 | } 158 | 159 | const outputChunk = rollupOutput.output[0] 160 | 161 | return outputChunk 162 | } 163 | 164 | /** 165 | * @param {EntryPointConfiguration} entryPointConfig 166 | * @param {number} entryPointIndex 167 | * @param {string} bundlePath 168 | * @param {import('rollup').OutputChunk} bundle 169 | */ 170 | async function getSingleExportBundleSizes(entryPointConfig, entryPointIndex, bundlePath, bundle) { 171 | if (entryPointConfig.format === 'esm' && bundle.exports.length !== 0) { 172 | const singleExportBundlesDirPath = path.resolve( 173 | actionRootPath, 174 | `temp/bundle-${entryPointIndex}`, 175 | ) 176 | 177 | await fs.mkdir(singleExportBundlesDirPath, { recursive: true }) 178 | 179 | return Promise.all( 180 | bundle.exports.map(async (exportName) => { 181 | const entryPoint = await createEntryPoint( 182 | singleExportBundlesDirPath, 183 | bundlePath, 184 | exportName, 185 | ) 186 | 187 | const singleExportBundle = await getBundle(entryPointConfig, entryPoint) 188 | 189 | return getBundleSize(exportName, singleExportBundle) 190 | }), 191 | ) 192 | } 193 | } 194 | 195 | /** 196 | * @param {string} singleExportBundlesDirPath 197 | * @param {string} bundlePath 198 | * @param {string} exportName 199 | */ 200 | async function createEntryPoint(singleExportBundlesDirPath, bundlePath, exportName) { 201 | const filePath = path.resolve(singleExportBundlesDirPath, `${exportName}.mjs`) 202 | const fileContent = `export { ${exportName} } from '${bundlePath}'` 203 | 204 | await fs.writeFile(filePath, fileContent) 205 | 206 | return filePath 207 | } 208 | 209 | /** 210 | * @typedef {object} BundleSize 211 | * @property {string} label 212 | * @property {number} size 213 | * @property {number} sizeMinified 214 | * @property {number} sizeBrotliCompressed 215 | */ 216 | 217 | /** 218 | * @param {string} label 219 | * @param {import('rollup').OutputChunk} bundle 220 | * @returns {Promise} 221 | */ 222 | async function getBundleSize(label, bundle) { 223 | const esBuildTransformResult = await transform(bundle.code, { minify: true }) 224 | const minifiedCode = esBuildTransformResult.code 225 | const brotliCompressedCode = (await brotliCompress(minifiedCode)).toString() 226 | 227 | return { 228 | label, 229 | size: bundle.code.length, 230 | sizeMinified: minifiedCode.length, 231 | sizeBrotliCompressed: brotliCompressedCode.length, 232 | } 233 | } 234 | 235 | const brotliCompress = promisify(zlib.brotliCompress) 236 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/get-size-metrics-report-content.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getDetails, getTableHtml, nonBreaking } from './utils/html.mjs' 4 | 5 | /** 6 | * @param {import('./get-package-size.mjs').EntryPointSize[]} entryPointSizesHead 7 | * @param {import('./get-package-size.mjs').EntryPointSize[]} entryPointSizesBase 8 | */ 9 | export function getSizeMetricsReportContent(entryPointSizesHead, entryPointSizesBase) { 10 | const entryPointSizeMetrics = getEntryPointSizeMetrics(entryPointSizesHead, entryPointSizesBase) 11 | 12 | const entryPointSizeChanges = entryPointSizeMetrics 13 | .filter(({ bundleSize, singleExportSizes }) => { 14 | return hasBundleSizeChange(bundleSize) || singleExportSizes?.some(hasBundleSizeChange) 15 | }) 16 | .map((entryPointSizeMetrics) => { 17 | if (!entryPointSizeMetrics.singleExportSizes) { 18 | return entryPointSizeMetrics 19 | } 20 | 21 | return { 22 | ...entryPointSizeMetrics, 23 | singleExportSizes: 24 | entryPointSizeMetrics.singleExportSizes.filter(hasBundleSizeChange), 25 | } 26 | }) 27 | 28 | return [ 29 | '### Size', 30 | entryPointSizeChanges.length 31 | ? getTableHtmlForSizeMetrics(entryPointSizeChanges) 32 | : 'No changes', 33 | getDetails({ 34 | summary: 'All size metrics', 35 | content: getTableHtmlForSizeMetrics(entryPointSizeMetrics), 36 | }), 37 | ].join('\n\n') 38 | } 39 | 40 | /** 41 | * @param {import('./get-package-size.mjs').EntryPointSize[]} entryPointSizesHead 42 | * @param {import('./get-package-size.mjs').EntryPointSize[]} entryPointSizesBase 43 | */ 44 | function getEntryPointSizeMetrics(entryPointSizesHead, entryPointSizesBase) { 45 | const baseEntryPointSizesMap = new Map( 46 | entryPointSizesBase.map((entryPointSize) => [ 47 | entryPointSize.bundleSize.label, 48 | entryPointSize, 49 | ]), 50 | ) 51 | 52 | return entryPointSizesHead.map(({ bundleSize, singleExportSizes }) => { 53 | const baseEntryPointSize = baseEntryPointSizesMap.get(bundleSize.label) 54 | 55 | const bundleSizeMetrics = getBundleSizeMetrics(bundleSize, baseEntryPointSize?.bundleSize) 56 | 57 | if (singleExportSizes) { 58 | const baseBundleSizeMap = new Map( 59 | baseEntryPointSize?.singleExportSizes?.map((bundleSize) => [ 60 | bundleSize.label, 61 | bundleSize, 62 | ]), 63 | ) 64 | 65 | return { 66 | bundleSize: bundleSizeMetrics, 67 | singleExportSizes: singleExportSizes.map((singleExportSize) => { 68 | const baseBundleSize = baseBundleSizeMap.get(singleExportSize.label) 69 | return getBundleSizeMetrics(singleExportSize, baseBundleSize) 70 | }), 71 | } 72 | } 73 | 74 | return { 75 | bundleSize: bundleSizeMetrics, 76 | } 77 | }) 78 | } 79 | 80 | /** 81 | * @param {import('./get-package-size.mjs').BundleSize} bundleSizeHead 82 | * @param {import('./get-package-size.mjs').BundleSize=} bundleSizeBase 83 | */ 84 | function getBundleSizeMetrics(bundleSizeHead, bundleSizeBase) { 85 | return { 86 | label: bundleSizeHead.label, 87 | size: getSizeMetrics(bundleSizeHead.size, bundleSizeBase?.size), 88 | sizeMinified: getSizeMetrics(bundleSizeHead.sizeMinified, bundleSizeBase?.sizeMinified), 89 | sizeBrotliCompressed: getSizeMetrics( 90 | bundleSizeHead.sizeBrotliCompressed, 91 | bundleSizeBase?.sizeBrotliCompressed, 92 | ), 93 | } 94 | } 95 | 96 | /** 97 | * @param {number} sizeHead 98 | * @param {number=} sizeBase 99 | */ 100 | function getSizeMetrics(sizeHead, sizeBase) { 101 | return { 102 | value: sizeHead, 103 | changePercent: sizeBase ? (sizeHead - sizeBase) / sizeBase : undefined, 104 | } 105 | } 106 | 107 | /** 108 | * 109 | * @param {ReturnType} entryPointSizeMetrics 110 | */ 111 | function getTableHtmlForSizeMetrics(entryPointSizeMetrics) { 112 | return getTableHtml({ 113 | headers: [ 114 | 'Export', 115 | 'Size original', 116 | 'Size minified', 117 | 'Size minified and Brotli compressed', 118 | ], 119 | columnAlignments: ['left', 'center', 'center', 'center'], 120 | columnWidths: ['225px', '200px', '200px', '200px'], 121 | rows: entryPointSizeMetrics.flatMap(({ bundleSize, singleExportSizes }) => { 122 | const mainBundleRow = getBundleSizeTableRow(bundleSize) 123 | 124 | if (singleExportSizes) { 125 | return [ 126 | mainBundleRow, 127 | ...singleExportSizes.map((singleExportSize) => { 128 | return getBundleSizeTableRow(singleExportSize, true) 129 | }), 130 | ] 131 | } 132 | 133 | return [mainBundleRow] 134 | }), 135 | }) 136 | } 137 | 138 | /** 139 | * @param {ReturnType} bundleSizeMetrics 140 | * @param {boolean=} isIndented 141 | */ 142 | function getBundleSizeTableRow(bundleSizeMetrics, isIndented) { 143 | return [ 144 | nonBreaking( 145 | [isIndented ? ' › ' : '', '', bundleSizeMetrics.label, ''].join(''), 146 | ), 147 | getSizeMetricsTableContent(bundleSizeMetrics.size), 148 | getSizeMetricsTableContent(bundleSizeMetrics.sizeMinified), 149 | getSizeMetricsTableContent(bundleSizeMetrics.sizeBrotliCompressed), 150 | ] 151 | } 152 | 153 | /** 154 | * @param {ReturnType} size 155 | */ 156 | function getSizeMetricsTableContent(size) { 157 | const sizeString = nonBreaking(getSizeInKb(size.value)) 158 | 159 | if (size.changePercent === undefined) { 160 | return sizeString 161 | } 162 | 163 | return sizeString + ' ' + nonBreaking(getChangePercentString(size.changePercent)) 164 | } 165 | 166 | /** 167 | * @param {number} size 168 | */ 169 | export function getSizeInKb(size) { 170 | return (size / 1024).toLocaleString('en-GB', { 171 | style: 'unit', 172 | unit: 'kilobyte', 173 | unitDisplay: 'short', 174 | minimumFractionDigits: 2, 175 | maximumFractionDigits: 2, 176 | }) 177 | } 178 | 179 | /** 180 | * @param {number} changePercent 181 | */ 182 | function getChangePercentString(changePercent) { 183 | const isZero = changePercent === 0 184 | const isPositive = changePercent > 0 185 | 186 | const percentageString = changePercent.toLocaleString('en-GB', { 187 | style: 'percent', 188 | minimumFractionDigits: isZero ? 0 : 1, 189 | maximumFractionDigits: 1, 190 | signDisplay: 'exceptZero', 191 | }) 192 | 193 | return percentageString + (isZero ? '' : isPositive ? ' 🔴' : ' 🟢') 194 | } 195 | 196 | /** 197 | * @param {ReturnType} bundleSizeMetrics 198 | */ 199 | function hasBundleSizeChange(bundleSizeMetrics) { 200 | return ( 201 | bundleSizeMetrics.size.changePercent !== 0 || 202 | bundleSizeMetrics.sizeMinified.changePercent !== 0 || 203 | bundleSizeMetrics.sizeBrotliCompressed.changePercent !== 0 204 | ) 205 | } 206 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/main.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import core from '@actions/core' 4 | import { context } from '@actions/github' 5 | 6 | import { getPackageSize } from './get-package-size.mjs' 7 | import { getSizeInKb, getSizeMetricsReportContent } from './get-size-metrics-report-content.mjs' 8 | import { setComment } from './set-comment.mjs' 9 | import { checkoutBranch } from './utils/git.mjs' 10 | 11 | run() 12 | 13 | async function run() { 14 | const pullRequest = 15 | /** @type {import('@octokit/webhooks-definitions/schema').PullRequestEvent} */ ( 16 | context.payload 17 | ).pull_request 18 | 19 | if (!pullRequest) { 20 | throw Error('This action can only be run in a pull request') 21 | } 22 | 23 | core.info('Getting local package sizes') 24 | const entryPointSizesHead = await getPackageSize() 25 | logEntryPointSizes(entryPointSizesHead) 26 | 27 | await checkoutBranch(pullRequest.base.ref) 28 | 29 | core.info('Getting PR base package sizes') 30 | const entryPointSizesBase = await getPackageSize({ shouldOmitFailures: true }) 31 | logEntryPointSizes(entryPointSizesBase) 32 | 33 | const commentBody = getBodyText([ 34 | ['### Metrics report'], 35 | [ 36 | `At head commit ${pullRequest.head?.sha} and base commit ${pullRequest.base?.sha} at \`${new Date().toISOString()}\``, 37 | ], 38 | [getSizeMetricsReportContent(entryPointSizesHead, entryPointSizesBase)], 39 | ]) 40 | 41 | const isPullRequestFromFork = 42 | pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}` 43 | 44 | if (isPullRequestFromFork) { 45 | core.info('Pull request is from a fork, printing comment instead of posting it') 46 | core.info(commentBody) 47 | } else { 48 | await setComment(commentBody) 49 | } 50 | } 51 | 52 | /** 53 | * @param {import('./get-package-size.mjs').EntryPointSize[]} entryPointSizes 54 | */ 55 | function logEntryPointSizes(entryPointSizes) { 56 | core.info('Package sizes') 57 | 58 | entryPointSizes.forEach(({ bundleSize, singleExportSizes }) => { 59 | logBundleSize(bundleSize) 60 | 61 | singleExportSizes?.forEach((singleExportSize) => { 62 | logBundleSize(singleExportSize, true) 63 | }) 64 | }) 65 | } 66 | 67 | /** 68 | * @param {import('./get-package-size.mjs').BundleSize} bundleSize 69 | * @param {boolean=} isIndented 70 | */ 71 | function logBundleSize(bundleSize, isIndented) { 72 | core.info( 73 | [ 74 | [isIndented ? ' ' : '', bundleSize.label].join('').padEnd(30), 75 | [ 76 | getSizeInKb(bundleSize.size).padStart(14), 77 | `${getSizeInKb(bundleSize.sizeMinified).padStart(14)} minified`, 78 | `${getSizeInKb(bundleSize.sizeBrotliCompressed).padStart(14)} brotli compressed`, 79 | ].join(' '), 80 | ].join(''), 81 | ) 82 | } 83 | 84 | /** 85 | * @param {string[][]} paragraphs 86 | */ 87 | function getBodyText(paragraphs) { 88 | return paragraphs.map((lines) => lines.join('\n')).join('\n\n') 89 | } 90 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/set-comment.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import core from '@actions/core' 4 | import { context } from '@actions/github' 5 | 6 | import { octokit } from './utils/github.mjs' 7 | 8 | const commentIdComment = '' 9 | const commentAdditionComment = 10 | "\n\n\n" 11 | 12 | /** 13 | * @param {string} body 14 | */ 15 | export async function setComment(body) { 16 | const commentToUpdate = await findCommentToUpdate() 17 | 18 | if (commentToUpdate) { 19 | await updateComment(body, commentToUpdate.id) 20 | } else { 21 | await createComment(body) 22 | } 23 | } 24 | 25 | async function findCommentToUpdate() { 26 | if (!context.payload.pull_request) { 27 | throw new Error('Can only list comments in a pull request') 28 | } 29 | 30 | core.info('Searching comment to update') 31 | const iterator = octokit.paginate.iterator(octokit.rest.issues.listComments, { 32 | owner: context.repo.owner, 33 | repo: context.repo.repo, 34 | issue_number: context.payload.pull_request.number, 35 | }) 36 | 37 | /** @type {import('@octokit/types').GetResponseDataTypeFromEndpointMethod[number]=} */ 38 | let commentToUpdate 39 | 40 | iteratorLoop: for await (const response of iterator) { 41 | for (const comment of response.data) { 42 | if (comment.body?.startsWith(commentIdComment)) { 43 | commentToUpdate = comment 44 | break iteratorLoop 45 | } 46 | } 47 | } 48 | 49 | if (commentToUpdate) { 50 | core.info(`Found comment to update with URL ${commentToUpdate.url}`) 51 | } else { 52 | core.info('No comment to update found') 53 | } 54 | 55 | return commentToUpdate 56 | } 57 | 58 | /** 59 | * @param {string} body 60 | */ 61 | async function createComment(body) { 62 | if (!context.payload.pull_request) { 63 | throw new Error('Can only create a comment in a pull request') 64 | } 65 | 66 | const response = await octokit.rest.issues.createComment({ 67 | owner: context.repo.owner, 68 | repo: context.repo.repo, 69 | issue_number: context.payload.pull_request.number, 70 | body: commentIdComment + commentAdditionComment + body, 71 | }) 72 | 73 | core.info(`Created comment with URL ${response.data.url}`) 74 | } 75 | 76 | /** 77 | * @param {string} body 78 | * @param {number} commentId 79 | */ 80 | async function updateComment(body, commentId) { 81 | const response = await octokit.rest.issues.updateComment({ 82 | owner: context.repo.owner, 83 | repo: context.repo.repo, 84 | comment_id: commentId, 85 | body: commentIdComment + commentAdditionComment + body, 86 | }) 87 | 88 | core.info(`Updated comment with URL ${response.data.url}`) 89 | } 90 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/utils/git.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import core from '@actions/core' 4 | import { exec } from '@actions/exec' 5 | 6 | /** 7 | * @param {string} branch 8 | */ 9 | export async function checkoutBranch(branch) { 10 | try { 11 | core.info(`Fetching branch ${branch}`) 12 | await exec(`git fetch origin ${branch} --depth=1`) 13 | } catch (error) { 14 | core.error('git fetch failed', error.message) 15 | 16 | throw error 17 | } 18 | 19 | core.info(`Checking out branch ${branch}`) 20 | await exec(`git checkout --force ${branch}`) 21 | } 22 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/utils/github.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import core from '@actions/core' 4 | import { getOctokit } from '@actions/github' 5 | 6 | const githubToken = core.getInput('github_token') 7 | export const octokit = getOctokit(githubToken) 8 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/utils/html.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {object} DetailsData 5 | * @property {string} summary 6 | * @property {string} content 7 | */ 8 | 9 | /** 10 | * @param {DetailsData} param0 11 | */ 12 | export function getDetails({ summary, content }) { 13 | return [`
`, `${summary}`, '', content, '', `
`].join('\n') 14 | } 15 | 16 | /** 17 | * @typedef {object} TableData 18 | * @property {('left' | 'center' | 'right' | undefined)[]=} columnAlignments 19 | * @property {(string | undefined)[]=} columnWidths 20 | * @property {string[]} headers 21 | * @property {string[][]} rows 22 | */ 23 | 24 | /** 25 | * @param {TableData} param0 26 | */ 27 | export function getTableHtml({ columnAlignments = [], columnWidths = [], headers, rows }) { 28 | return getHtml([ 29 | { 30 | tag: 'table', 31 | children: [ 32 | { 33 | tag: 'thead', 34 | children: [ 35 | { 36 | tag: 'tr', 37 | children: headers.map((header, headerIndex) => ({ 38 | tag: 'th', 39 | attributes: { 40 | width: columnWidths[headerIndex], 41 | align: columnAlignments[headerIndex], 42 | }, 43 | children: [header], 44 | })), 45 | }, 46 | ], 47 | }, 48 | { 49 | tag: 'tbody', 50 | children: rows.map((row) => ({ 51 | tag: 'tr', 52 | children: row.map((cell, cellIndex) => ({ 53 | tag: 'td', 54 | attributes: { 55 | align: columnAlignments[cellIndex], 56 | }, 57 | children: [cell], 58 | })), 59 | })), 60 | }, 61 | ], 62 | }, 63 | ]) 64 | } 65 | 66 | /** 67 | * @typedef {object} HtmlElement 68 | * @property {string} tag 69 | * @property {Record=} attributes 70 | * @property {(HtmlElement | string)[]=} children 71 | */ 72 | 73 | /** 74 | * @param {(HtmlElement | string)[]} elements 75 | */ 76 | function getHtml(elements) { 77 | return indent(getHtmlLinesToIndent(elements)) 78 | } 79 | 80 | /** 81 | * @typedef {(string | LinesToIndent)[]} LinesToIndent 82 | */ 83 | 84 | /** 85 | * @param {LinesToIndent} lines 86 | * @param {number=} level 87 | * @returns {string} 88 | */ 89 | function indent(lines, level = 0) { 90 | const indentation = ' '.repeat(level * 4) 91 | return lines 92 | .map((element) => { 93 | if (typeof element === 'string') { 94 | return indentation + element 95 | } 96 | 97 | return indent(element, level + 1) 98 | }) 99 | .join('\n') 100 | } 101 | 102 | /** 103 | * @param {(HtmlElement | string)[]} elements 104 | * @returns {LinesToIndent} 105 | */ 106 | function getHtmlLinesToIndent(elements) { 107 | return elements.flatMap((element) => { 108 | if (typeof element === 'string') { 109 | return element 110 | } 111 | 112 | const attributes = Object.entries(element.attributes ?? {}) 113 | .filter(([, value]) => value !== undefined) 114 | .map(([key, value]) => `${key}="${value}"`) 115 | .join(' ') 116 | const openingTag = `<${element.tag}${attributes ? ' ' + attributes : ''}>` 117 | const closingTag = `` 118 | const innerHtmlLines = element.children ? getHtmlLinesToIndent(element.children) : [] 119 | 120 | if (innerHtmlLines.length === 0) { 121 | return [openingTag, closingTag] 122 | } 123 | 124 | return [openingTag, innerHtmlLines, closingTag] 125 | }) 126 | } 127 | 128 | /** 129 | * @param {string} text 130 | */ 131 | export function nonBreaking(text) { 132 | return text.replace(/ /g, ' ') 133 | } 134 | -------------------------------------------------------------------------------- /.github/actions/metrics-report/src/utils/path.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import path from 'path' 4 | import { fileURLToPath } from 'url' 5 | 6 | const currentDirPath = path.dirname(fileURLToPath(import.meta.url)) 7 | 8 | export const actionRootPath = path.resolve(currentDirPath, '../..') 9 | export const repoRootPath = path.resolve(actionRootPath, '../../..') 10 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: dcastil 2 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | template: | 4 | $CHANGES 5 | 6 | **Full Changelog**: https://github.com/dcastil/tailwind-merge/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 7 | category-template: '### $TITLE' 8 | change-template: '- $TITLE by @$AUTHOR in https://github.com/dcastil/tailwind-merge/pull/$NUMBER' 9 | change-title-escapes: '\<*_&' 10 | no-changes-template: 'No changes' 11 | categories: 12 | - title: '⚠️ Needs Changelog Edit' 13 | label: 'needs changelog edit' 14 | - title: 'Breaking Changes' 15 | label: 'breaking' 16 | - title: 'New Features' 17 | label: 'feature' 18 | - title: 'Bug Fixes' 19 | label: 'bug' 20 | - title: 'Documentation' 21 | label: 'documentation' 22 | - title: 'Other' 23 | label: 'other' 24 | exclude-labels: 25 | - 'skip changelog' 26 | version-resolver: 27 | major: 28 | labels: 29 | - 'breaking' 30 | minor: 31 | labels: 32 | - 'feature' 33 | patch: 34 | labels: 35 | - 'bug' 36 | - 'documentation' 37 | - 'other' 38 | default: 'patch' 39 | autolabeler: 40 | - label: 'feature' 41 | branch: 42 | - '/\bfeature\b/i' 43 | title: 44 | - '/\bfeature\b/i' 45 | - label: 'bug' 46 | branch: 47 | - '/\b(bug|bugfix|fix)\b/i' 48 | title: 49 | - '/\b(bug|bugfix|fix)\b/i' 50 | - label: 'documentation' 51 | branch: 52 | - '/\b(documentation|docs)\b/i' 53 | title: 54 | - '/\b(documentation|docs)\b/i' 55 | - label: 'other' 56 | branch: 57 | - '/^other\b/i' 58 | - label: 'breaking' 59 | branch: 60 | - '/^breaking-/i' 61 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended", 4 | ":combinePatchMinorReleases", 5 | "group:allNonMajor", 6 | ":labels(dependencies,skip changelog)", 7 | ":assignee(dcastil)", 8 | ":enableVulnerabilityAlertsWithLabel(security)", 9 | "schedule:monthly" 10 | ], 11 | "timezone": "Europe/Berlin", 12 | "prHourlyLimit": 10, 13 | "rangeStrategy": "bump" 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | benchmark: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 22 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.16.0 18 | - name: Use node_modules cache 19 | uses: actions/cache@v4 20 | with: 21 | path: node_modules 22 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 23 | restore-keys: | 24 | yarn-node-22-lock- 25 | - run: yarn install --frozen-lockfile 26 | - name: Run benchmark 27 | uses: CodSpeedHQ/action@v3 28 | with: 29 | token: ${{ secrets.CODSPEED_TOKEN }} 30 | run: yarn bench 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | schedule: 7 | - cron: '30 8 * * 5' 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | actions: read 15 | contents: read 16 | security-events: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js 22 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22.16.0 24 | - name: Use node_modules cache 25 | uses: actions/cache@v4 26 | with: 27 | path: node_modules 28 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 29 | restore-keys: | 30 | yarn-node-22-lock- 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | with: 34 | languages: javascript 35 | - run: yarn install --frozen-lockfile 36 | - run: yarn build 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v3 39 | -------------------------------------------------------------------------------- /.github/workflows/comment-released-prs-and-issues.yml: -------------------------------------------------------------------------------- 1 | name: Comment released PRs and issues 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - uses: apexskier/github-release-commenter@v1 15 | with: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | comment-template: This was addressed in release {release_link}. 18 | skip-label: skip-release-comment 19 | -------------------------------------------------------------------------------- /.github/workflows/draft-release.yml: -------------------------------------------------------------------------------- 1 | name: Draft Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v6 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | permissions: write-all 17 | -------------------------------------------------------------------------------- /.github/workflows/label.yml: -------------------------------------------------------------------------------- 1 | name: Auto Label New Issues, PRs, and Discussions 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | pull_request_target: 7 | types: [opened] 8 | discussion: 9 | types: [created] 10 | 11 | jobs: 12 | add-label: 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | issues: write 17 | discussions: write 18 | pull-requests: write 19 | 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | REPO_OWNER: ${{ github.repository_owner }} 23 | REPO_NAME: ${{ github.event.repository.name }} 24 | LABEL_NAME: context-v3 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Add label to new issue 31 | if: github.event_name == 'issues' 32 | run: | 33 | ISSUE_NUMBER=${{ github.event.issue.number }} 34 | gh issue edit "$ISSUE_NUMBER" --add-label "$LABEL_NAME" 35 | 36 | - name: Add label to new PR 37 | if: github.event_name == 'pull_request_target' 38 | run: | 39 | PR_NUMBER=${{ github.event.pull_request.number }} 40 | gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" 41 | 42 | - name: Get label id for discussion 43 | id: label-data 44 | if: github.event_name == 'discussion' 45 | run: | 46 | res="$(gh api \ 47 | -H "Accept: application/vnd.github+json" \ 48 | -H "X-GitHub-Api-Version: 2022-11-28" \ 49 | /repos/$REPO_OWNER/$REPO_NAME/labels/$LABEL_NAME --jq '.node_id')" 50 | echo "label_id=$res" >> $GITHUB_OUTPUT 51 | 52 | - name: Add label to new discussion 53 | uses: octokit/graphql-action@v2.x 54 | if: github.event_name == 'discussion' 55 | env: 56 | DISCUSSION_ID: ${{ github.event.discussion.node_id }} 57 | LABEL_ID: ${{ steps.label-data.outputs.label_id }} 58 | with: 59 | query: | 60 | mutation { 61 | addLabelsToLabelable( 62 | input:{ 63 | labelableId: "${{env.DISCUSSION_ID}}" 64 | labelIds: ["${{ env.LABEL_ID}}"] 65 | } 66 | ) { 67 | clientMutationId 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/metrics-report.yml: -------------------------------------------------------------------------------- 1 | name: Metrics report 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | metrics-report: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | pull-requests: write 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 22 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 22.16.0 16 | - name: Restore node_modules cache 17 | uses: actions/cache/restore@v4 18 | with: 19 | path: node_modules 20 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 21 | restore-keys: | 22 | yarn-node-22-lock- 23 | - run: yarn --frozen-lockfile 24 | - name: Save node_modules cache 25 | uses: actions/cache/save@v4 26 | with: 27 | path: node_modules 28 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 29 | - name: Use node_modules cache for metrics-report-action 30 | uses: actions/cache@v4 31 | with: 32 | path: .github/actions/metrics-report/node_modules 33 | key: yarn-node-22-metrics-report-action-lock-${{ hashFiles('.github/actions/metrics-report/yarn.lock') }} 34 | restore-keys: | 35 | yarn-node-22-metrics-report-action-lock- 36 | - run: yarn --cwd .github/actions/metrics-report install --frozen-lockfile 37 | - uses: ./.github/actions/metrics-report 38 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish-dev.yml: -------------------------------------------------------------------------------- 1 | name: npm Publish dev 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 22 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.16.0 18 | # More info: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages#publishing-packages-to-the-npm-registry 19 | registry-url: 'https://registry.npmjs.org' 20 | - name: Use node_modules cache 21 | uses: actions/cache@v4 22 | with: 23 | path: node_modules 24 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 25 | restore-keys: | 26 | yarn-node-22-lock- 27 | - run: yarn install --frozen-lockfile 28 | - run: yarn build 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: build-output 32 | path: dist 33 | if-no-files-found: error 34 | - uses: martinbeentjes/npm-get-version-action@v1.3.1 35 | id: package-version 36 | - run: yarn version --no-git-tag-version --new-version ${{ steps.package-version.outputs.current-version }}-dev.${{ github.sha }} 37 | # npm install -g npm@latest is necessary to make provenance available (available since v9.6.5 or so). More info: https://docs.npmjs.com/generating-provenance-statements 38 | - run: npm install -g npm@latest 39 | - run: npm publish --access public --tag dev 40 | env: 41 | # Is connected with actions/setup-node -> registry-url 42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: npm Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 22 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.16.0 18 | - name: Use node_modules cache 19 | uses: actions/cache@v4 20 | with: 21 | path: node_modules 22 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 23 | restore-keys: | 24 | yarn-node-22-lock- 25 | - run: yarn install --frozen-lockfile 26 | - run: yarn lint 27 | - run: yarn test 28 | - run: yarn build 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: build-output 32 | path: dist 33 | if-no-files-found: error 34 | - run: yarn test:exports 35 | # npm install -g npm@latest is necessary to make provenance available (available since v9.6.5 or so). More info: https://docs.npmjs.com/generating-provenance-statements 36 | - run: npm install -g npm@latest 37 | - uses: JS-DevTools/npm-publish@v3 38 | with: 39 | token: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ['**'] 6 | pull_request: 7 | branches: ['**'] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 22 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.16.0 18 | - name: Use node_modules cache 19 | uses: actions/cache@v4 20 | with: 21 | path: node_modules 22 | key: yarn-node-22-lock-${{ hashFiles('yarn.lock') }} 23 | restore-keys: | 24 | yarn-node-22-lock- 25 | - run: yarn install --frozen-lockfile 26 | - run: yarn lint 27 | - run: yarn test 28 | - run: yarn build 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: build-output 32 | path: dist 33 | if-no-files-found: error 34 | - run: yarn test:exports 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode/ 2 | /dist/ 3 | coverage 4 | node_modules/ 5 | .DS_Store 6 | *.local 7 | .idea -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": false, 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dany Castillo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 | tailwind-merge 7 | 8 |
9 | 10 | # tailwind-merge 11 | 12 | Utility function to efficiently merge [Tailwind CSS](https://tailwindcss.com) classes in JS without style conflicts. 13 | 14 | ```ts 15 | import { twMerge } from 'tailwind-merge' 16 | 17 | twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]') 18 | // → 'hover:bg-dark-red p-3 bg-[#B91C1C]' 19 | ``` 20 | 21 | - 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)) 22 | - Works in all modern browsers and maintained Node versions 23 | - Fully typed 24 | - [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge) 25 | 26 | ## Get started 27 | 28 | - [What is it for](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/what-is-it-for.md) 29 | - [When and how to use it](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/when-and-how-to-use-it.md) 30 | - [Features](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/features.md) 31 | - [Limitations](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/limitations.md) 32 | - [Configuration](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/configuration.md) 33 | - [Recipes](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/recipes.md) 34 | - [API reference](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/api-reference.md) 35 | - [Writing plugins](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/writing-plugins.md) 36 | - [Versioning](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/versioning.md) 37 | - [Contributing](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/contributing.md) 38 | - [Similar packages](https://github.com/dcastil/tailwind-merge/tree/v3.3.0/docs/similar-packages.md) 39 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | tailwind-merge 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | tailwind-merge 5 | 6 |
7 | 8 | # tailwind-merge 9 | 10 | Utility function to efficiently merge [Tailwind CSS](https://tailwindcss.com) classes in JS without style conflicts. 11 | 12 | ```ts 13 | import { twMerge } from 'tailwind-merge' 14 | 15 | twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]') 16 | // → 'hover:bg-dark-red p-3 bg-[#B91C1C]' 17 | ``` 18 | 19 | - 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)) 20 | - Works in all modern browsers and maintained Node versions 21 | - Fully typed 22 | - [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge) 23 | 24 | ## Get started 25 | 26 | - [What is it for](./what-is-it-for.md) 27 | - [When and how to use it](./when-and-how-to-use-it.md) 28 | - [Features](./features.md) 29 | - [Limitations](./limitations.md) 30 | - [Configuration](./configuration.md) 31 | - [Recipes](./recipes.md) 32 | - [API reference](./api-reference.md) 33 | - [Writing plugins](./writing-plugins.md) 34 | - [Versioning](./versioning.md) 35 | - [Contributing](./contributing.md) 36 | - [Similar packages](./similar-packages.md) 37 | -------------------------------------------------------------------------------- /docs/changelog/v0-to-v1-migration.md: -------------------------------------------------------------------------------- 1 | # Guide to migrate from tailwind-merge v0 to v1 2 | 3 | This document is only about breaking changes between v0 and v1. For a full list of changes, see the [v1.0.0 release](./v1-changelog.md#v100). 4 | 5 | ## Overview 6 | 7 | tailwind-merge v1 drops support for Tailwind CSS v2 and in turn adds support for Tailwind CSS v3. 8 | 9 | There are no breaking changes in the tailwind-merge types and some breaking changes for a small number of users in the return values, so you should get through smoothly. 10 | 11 | ## Breaking changes 12 | 13 | ### `twMerge` and `extendTailwindMerge` 14 | 15 | - Outline utilities from Tailwind v2 don't get merged anymore since they were replaced by outline width, outline style, outline offset and outline color in Tailwind v3 ([`55ab167`](https://github.com/dcastil/tailwind-merge/commit/55ab167b7167519873c5dd4d258dc62212d1659a), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 16 | - The classes `overflow-ellipsis` and `overflow-clip` will not get merged with class `truncate` anymore, but the new Tailwind v3 classes `text-ellipsis` and `text-clip` will. ([`65b03e4`](https://github.com/dcastil/tailwind-merge/commit/65b03e48914ac5d7d52eea9ec178b204d30609c9), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 17 | - The classes `decoration-slice` and `decoration-clone` won't get merged anymore and `box-decoration-slide` nad `box-decoration-clone` will ([`bfe2cc9`](https://github.com/dcastil/tailwind-merge/commit/bfe2cc9bb221107fa0bf363cc325ddbb04677f43), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 18 | 19 | ### `getDefaultConfig` 20 | 21 | - Removed class group `outline` since it was removed in Tailwind v3 ([`55ab167`](https://github.com/dcastil/tailwind-merge/commit/55ab167b7167519873c5dd4d258dc62212d1659a), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 22 | - Renamed class group `vertival-alignment` (yes, the typo was in the code) to `vertical-align` ([`1269ce6`](https://github.com/dcastil/tailwind-merge/commit/1269ce68ae39807ceadbecc98c0929fdfdb446d0), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 23 | - Renamed class groups `flex-basis`, `flex-grow` and `flex-shrink` to `basis`, `grow` and `shrink` to stay consistent with Tailwind v3 ([`e6d8912`](https://github.com/dcastil/tailwind-merge/commit/e6d8912e47bf9a89346b9b0cc822fb2bff2af172), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 24 | 25 | ### `validators` 26 | 27 | - `isCustomLength` and `isCustomValue` were renamed to `isArbitraryLength` and `isArbitraryValue` to be consistent with naming in Tailwind v3 documentation ([`adc3c02`](https://github.com/dcastil/tailwind-merge/commit/adc3c02c7f035069beec1c62777ec008172587ab), [#63](https://github.com/dcastil/tailwind-merge/pull/63)) 28 | 29 | ## Steps to upgrade 30 | 31 | - Upgrade to Tailwind CSS v3 32 | - Upgrade to tailwind-merge v1 33 | -------------------------------------------------------------------------------- /docs/changelog/v3-changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog for v3 releases 2 | 3 | ## v3.3.0 4 | 5 | ### New Features 6 | 7 | - Add support for tailwind CSS v4.1.5 by [@dcastil](https://github.com/dcastil) in [#575](https://github.com/dcastil/tailwind-merge/pull/575) 8 | 9 | **Full Changelog**: [`v3.2.0...v3.3.0`](https://github.com/dcastil/tailwind-merge/compare/v3.2.0...v3.3.0) 10 | 11 | Thanks to [@brandonmcconnell](https://github.com/brandonmcconnell), [@manavm1990](https://github.com/manavm1990), [@langy](https://github.com/langy), [@roboflow](https://github.com/roboflow), [@syntaxfm](https://github.com/syntaxfm), [@getsentry](https://github.com/getsentry), [@codecov](https://github.com/codecov), [@sourcegraph](https://github.com/sourcegraph), a private sponsor and [@block](https://github.com/block) for sponsoring tailwind-merge! ❤️ 12 | 13 | ## v3.2.0 14 | 15 | ### New Features 16 | 17 | - Add support for Tailwind CSS v4.1 by [@dcastil](https://github.com/dcastil) in [#565](https://github.com/dcastil/tailwind-merge/pull/565) 18 | 19 | **Full Changelog**: [`v3.1.0...v3.2.0`](https://github.com/dcastil/tailwind-merge/compare/v3.1.0...v3.2.0) 20 | 21 | Thanks to [@brandonmcconnell](https://github.com/brandonmcconnell), [@manavm1990](https://github.com/manavm1990), [@langy](https://github.com/langy), [@jamesreaco](https://github.com/jamesreaco), [@roboflow](https://github.com/roboflow), [@syntaxfm](https://github.com/syntaxfm), [@getsentry](https://github.com/getsentry), [@codecov](https://github.com/codecov), [@sourcegraph](https://github.com/sourcegraph) and a private sponsor for sponsoring tailwind-merge! ❤️ 22 | 23 | ## v3.1.0 24 | 25 | ### New Features 26 | 27 | - Add support for Tailwind CSS v4.0.10 by [@dcastil](https://github.com/dcastil) in [#546](https://github.com/dcastil/tailwind-merge/pull/546) 28 | 29 | ### Bug Fixes 30 | 31 | - Fix length variable in `via-(length:*)` class being merged with `via-` classes accidentally by [@dcastil](https://github.com/dcastil) in [#559](https://github.com/dcastil/tailwind-merge/pull/559) 32 | 33 | ### Documentation 34 | 35 | - Fix typo in comment in types.ts by [@roottool](https://github.com/roottool) in [#549](https://github.com/dcastil/tailwind-merge/pull/549) 36 | - Update shadow scale recipe to tailwind merge v3 API by [@dcastil](https://github.com/dcastil) in [#545](https://github.com/dcastil/tailwind-merge/pull/545) 37 | 38 | ### Other 39 | 40 | - Fix metrics report action erroring on PRs from forks by [@dcastil](https://github.com/dcastil) in [#551](https://github.com/dcastil/tailwind-merge/pull/551) 41 | 42 | **Full Changelog**: [`v3.0.2...v3.1.0`](https://github.com/dcastil/tailwind-merge/compare/v3.0.2...v3.1.0) 43 | 44 | Thanks to [@brandonmcconnell](https://github.com/brandonmcconnell), [@manavm1990](https://github.com/manavm1990), [@langy](https://github.com/langy), [@jamesreaco](https://github.com/jamesreaco), [@roboflow](https://github.com/roboflow), [@syntaxfm](https://github.com/syntaxfm), [@getsentry](https://github.com/getsentry), [@codecov](https://github.com/codecov), [@sourcegraph](https://github.com/sourcegraph) and a private sponsor for sponsoring tailwind-merge! ❤️ 45 | 46 | ## v3.0.2 47 | 48 | ### Bug Fixes 49 | 50 | - Fix `px` value not being recognized for some class groups by [@dcastil](https://github.com/dcastil) in [#538](https://github.com/dcastil/tailwind-merge/pull/538) 51 | - Fix doc comment being in incorrect place in default config by [@gjtorikian](https://github.com/gjtorikian) in [#526](https://github.com/dcastil/tailwind-merge/pull/526) 52 | 53 | **Full Changelog**: [`v3.0.1...v3.0.2](https://github.com/dcastil/tailwind-merge/compare/v3.0.1...v3.0.2) 54 | 55 | Thanks to [@brandonmcconnell](https://github.com/brandonmcconnell), [@manavm1990](https://github.com/manavm1990), [@langy](https://github.com/langy), [@jamesreaco](https://github.com/jamesreaco), [@roboflow](https://github.com/roboflow), [@syntaxfm](https://github.com/syntaxfm), [@getsentry](https://github.com/getsentry), [@codecov](https://github.com/codecov), [@sourcegraph](https://github.com/sourcegraph) and a private sponsor for sponsoring tailwind-merge! ❤️ 56 | 57 | ## v3.0.1 58 | 59 | ### Bug Fixes 60 | 61 | - Update info about supported Tailwind CSS version in README by [@dcastil](https://github.com/dcastil) in [`b9c136d`](https://github.com/dcastil/tailwind-merge/commit/b9c136df358ef6012f23bf08258dbf970c0aec43) 62 | - Update incorrect link in v3 changelog by [@dcastil](https://github.com/dcastil) in [`e22885e`](https://github.com/dcastil/tailwind-merge/commit/e22885e41e1661f1493f9bf6fb829cfbe1b50281) 63 | 64 | **Full Changelog**: [`v3.0.0...v3.0.1`](https://github.com/dcastil/tailwind-merge/compare/v3.0.0...v3.0.1) 65 | 66 | Thanks to [@brandonmcconnell](https://github.com/brandonmcconnell), [@manavm1990](https://github.com/manavm1990), [@langy](https://github.com/langy), [@jamesreaco](https://github.com/jamesreaco), [@roboflow](https://github.com/roboflow), [@syntaxfm](https://github.com/syntaxfm), [@getsentry](https://github.com/getsentry), [@codecov](https://github.com/codecov), [@sourcegraph](https://github.com/sourcegraph) and a private sponsor for sponsoring tailwind-merge! ❤️ 67 | 68 | ## v3.0.0 69 | 70 | [Tailwind CSS v4 is here](https://tailwindcss.com/blog/tailwindcss-v4) and it's time to upgrade tailwind-merge to support it. tailwind-merge v3.0.0 is more accurate than ever and follows the Tailwind CSS spec more closely than in v2. That is thanks to Tailwind CSS v4 being more consistent than ever. 71 | 72 | This release drops support for Tailwind CSS v3 and in turn adds support for Tailwind CSS v4. That means you should upgrade to Tailwind CSS v4 and tailwind-merge v3 together. All breaking changes are related to the Tailwind CSS v4 support. 73 | 74 | Check out the [migration guide](./v2-to-v3-migration.md) and if you have any questions, feel free to [create an issue](https://github.com/dcastil/tailwind-merge/issues/new/choose). 75 | 76 | ### Breaking Changes 77 | 78 | - Dropping support for Tailwind CSS v3 in favor of support for Tailwind CSS v4 by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 79 | - Theme scales keys changed and now match Tailwind CSS v4 theme variable namespace exactly by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 80 | - `isLength` validator was removed and split into separate validators `isNumber` and `isFraction` by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 81 | - Prefix defined in config shouldn't include combining `-` character anymore by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 82 | - Tailwind CSS v3 prefix position in class not supported anymore in favor of Tailwind CSS v4 position by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 83 | - Custom separators are no longer supported by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 84 | - New mandatory `orderSensitiveModifiers` property in config when using `createTailwindMerge` by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 85 | - `DefaultThemeGroupIds` type union consists of different string literals than before by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 86 | - Classes removed in Tailwind CSS v4 are not supported by tailwind-merge anymore by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 87 | 88 | ### New Features 89 | 90 | - Support for new important modifier position at the end of class by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 91 | - Support for arbitrary CSS variable syntax by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 92 | - There are a bunch of new validators used by tailwind-merge, primarily for new Tailwind CSS v4 features like arbitrary CSS variables by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 93 | 94 | ### Bug Fixes 95 | 96 | - Previously some order-sensitive modifiers like `before:` were treated as not order-sensitive. This is now fixed by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 97 | 98 | ### Documentation 99 | 100 | - Added section explaining order-sensitive modifiers to [configuration docs](../configuration.md#order-sensitive-modifiers) by [@dcastil](https://github.com/dcastil) in [#518](https://github.com/dcastil/tailwind-merge/pull/518) 101 | 102 | **Full Changelog**: [`v2.6.0...v3.0.0`](https://github.com/dcastil/tailwind-merge/compare/v2.6.0...v3.0.0) 103 | 104 | Thanks to [@brandonmcconnell](https://github.com/brandonmcconnell), [@manavm1990](https://github.com/manavm1990), [@langy](https://github.com/langy), [@jamesreaco](https://github.com/jamesreaco), [@roboflow](https://github.com/roboflow), [@syntaxfm](https://github.com/syntaxfm), [@getsentry](https://github.com/getsentry), [@codecov](https://github.com/codecov), [@sourcegraph](https://github.com/sourcegraph) and a private sponsor for sponsoring tailwind-merge! ❤️ 105 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please see [CONTRIBUTING](../.github/CONTRIBUTING.md) for details. 4 | 5 | --- 6 | 7 | Next: [Similar packages](./similar-packages.md) 8 | 9 | Previous: [Versioning](./versioning.md) 10 | 11 | [Back to overview](./README.md) 12 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | ## Merging behavior 4 | 5 | tailwind-merge is built to be intuitive. It follows a set of rules to determine which class wins when there are conflicts. Here is a brief overview of its conflict resolution. 6 | 7 | ### Last conflicting class wins 8 | 9 | ```ts 10 | twMerge('p-5 p-2 p-4') // → 'p-4' 11 | ``` 12 | 13 | ### Allows refinements 14 | 15 | ```ts 16 | twMerge('p-3 px-5') // → 'p-3 px-5' 17 | twMerge('inset-x-4 right-4') // → 'inset-x-4 right-4' 18 | ``` 19 | 20 | ### Resolves non-trivial conflicts 21 | 22 | ```ts 23 | twMerge('inset-x-px -inset-1') // → '-inset-1' 24 | twMerge('bottom-auto inset-y-6') // → 'inset-y-6' 25 | twMerge('inline block') // → 'block' 26 | ``` 27 | 28 | ### Supports modifiers and stacked modifiers 29 | 30 | ```ts 31 | twMerge('p-2 hover:p-4') // → 'p-2 hover:p-4' 32 | twMerge('hover:p-2 hover:p-4') // → 'hover:p-4' 33 | twMerge('hover:focus:p-2 focus:hover:p-4') // → 'focus:hover:p-4' 34 | ``` 35 | 36 | tailwind-merge knows when the order of standard modifiers matters and when not and resolves conflicts accordingly. 37 | 38 | ### Supports arbitrary values 39 | 40 | ```ts 41 | twMerge('bg-black bg-(--my-color) bg-[color:var(--mystery-var)]') 42 | // → 'bg-[color:var(--mystery-var)]' 43 | twMerge('grid-cols-[1fr,auto] grid-cols-2') // → 'grid-cols-2' 44 | ``` 45 | 46 | > [!Note] 47 | > Labels necessary in ambiguous cases 48 | > 49 | > When using arbitrary values in ambiguous classes like `text-[calc(var(--rebecca)-1rem)]` tailwind-merge looks at the arbitrary value for clues to determine what type of class it is. In this case, like in most ambiguous classes, it would try to figure out whether `calc(var(--rebecca)-1rem)` is a length (making it a font-size class) or a color (making it a text-color class). For lengths it takes clues into account like the presence of the `calc()` function or a digit followed by a length unit like `1rem`. 50 | > 51 | > But it isn't always possible to figure out the type by looking at the arbitrary value. E.g. in the class `text-[theme(myCustomScale.rebecca)]` tailwind-merge can't know the type of the arbitrary value and will default to a text-color class. To make tailwind-merge understand the correct type of the arbitrary value in those cases, you can use CSS data type labels [which are used by Tailwind CSS to disambiguate classes](https://tailwindcss.com/docs/adding-custom-styles#resolving-ambiguities): `text-[length:theme(myCustomScale.rebecca)]`. 52 | 53 | ### Supports arbitrary properties 54 | 55 | ```ts 56 | twMerge('[mask-type:luminance] [mask-type:alpha]') // → '[mask-type:alpha]' 57 | twMerge('[--scroll-offset:56px] lg:[--scroll-offset:44px]') 58 | // → '[--scroll-offset:56px] lg:[--scroll-offset:44px]' 59 | 60 | // Don't do this! 61 | twMerge('[padding:1rem] p-8') // → '[padding:1rem] p-8' 62 | ``` 63 | 64 | > [!Note] 65 | > tailwind-merge does not resolve conflicts between arbitrary properties and their matching Tailwind classes to keep the bundle size small. 66 | 67 | ### Supports arbitrary variants 68 | 69 | ```ts 70 | twMerge('[&:nth-child(3)]:py-0 [&:nth-child(3)]:py-4') // → '[&:nth-child(3)]:py-4' 71 | twMerge('dark:hover:[&:nth-child(3)]:py-0 hover:dark:[&:nth-child(3)]:py-4') 72 | // → 'hover:dark:[&:nth-child(3)]:py-4' 73 | 74 | // Don't do this! 75 | twMerge('[&:focus]:ring focus:ring-4') // → '[&:focus]:ring focus:ring-4' 76 | ``` 77 | 78 | > [!Note] 79 | > Similarly to arbitrary properties, tailwind-merge does not resolve conflicts between arbitrary variants and their matching predefined modifiers for bundle size reasons. 80 | 81 | The order of standard modifiers before and after an arbitrary variant in isolation (all modifiers before are one group, all modifiers after are another group) does not matter for tailwind-merge. However, it does matter whether a standard modifier is before or after an arbitrary variant both for Tailwind CSS and tailwind-merge because the resulting CSS selectors are different. 82 | 83 | ### Supports important modifier 84 | 85 | ```ts 86 | twMerge('p-3! p-4! p-5') // → 'p-4! p-5' 87 | twMerge('right-2! -inset-x-1!') // → '-inset-x-1!' 88 | ``` 89 | 90 | ### Supports postfix modifiers 91 | 92 | ```ts 93 | twMerge('text-sm leading-6 text-lg/7') // → 'text-lg/7' 94 | ``` 95 | 96 | ### Preserves non-Tailwind classes 97 | 98 | ```ts 99 | twMerge('p-5 p-2 my-non-tailwind-class p-4') // → 'my-non-tailwind-class p-4' 100 | ``` 101 | 102 | ### Supports custom colors out of the box 103 | 104 | ```ts 105 | twMerge('text-red text-secret-sauce') // → 'text-secret-sauce' 106 | ``` 107 | 108 | ## Composition 109 | 110 | tailwind-merge has some features that simplify composing class strings together. Those allow you to compose classes like in [clsx](https://www.npmjs.com/package/clsx), [classnames](https://www.npmjs.com/package/classnames) or [classix](https://www.npmjs.com/package/classix). 111 | 112 | ### Supports multiple arguments 113 | 114 | ```ts 115 | twMerge('some-class', 'another-class yet-another-class', 'so-many-classes') 116 | // → 'some-class another-class yet-another-class so-many-classes' 117 | ``` 118 | 119 | ### Supports conditional classes 120 | 121 | ```ts 122 | twMerge('some-class', undefined, null, false, 0) // → 'some-class' 123 | twMerge('my-class', false && 'not-this', null && 'also-not-this', true && 'but-this') 124 | // → 'my-class but-this' 125 | ``` 126 | 127 | ### Supports arrays and nested arrays 128 | 129 | ```ts 130 | twMerge('some-class', [undefined, ['another-class', false]], ['third-class']) 131 | // → 'some-class another-class third-class' 132 | twMerge('hi', true && ['hello', ['hey', false]], false && ['bye']) 133 | // → 'hi hello hey' 134 | ``` 135 | 136 | Why no object support? [Read here](https://github.com/dcastil/tailwind-merge/discussions/137#discussioncomment-3481605). 137 | 138 | ## Performance 139 | 140 | tailwind-merge is optimized for speed when running in the browser. This includes the speed of loading the code and the speed of running the code. 141 | 142 | ### Results are cached 143 | 144 | Results get cached by default, so you don't need to worry about wasteful re-renders. The library uses a computationally lightweight [LRU cache]() which stores up to 500 different results by default. The cache is applied after all arguments are [joined](./api-reference.md#twjoin) together to a single string. This means that if you call `twMerge` repeatedly with different arguments that result in the same string when joined, the cache will be hit. 145 | 146 | The cache size can be modified or opt-out of by using [`extendTailwindMerge`](./api-reference.md#extendtailwindmerge). 147 | 148 | ### Data structures are reused between calls 149 | 150 | Expensive computations happen upfront so that `twMerge` calls without a cache hit stay fast. 151 | 152 | ### Lazy initialization 153 | 154 | The initial computations are called lazily on the first call to `twMerge` to prevent it from impacting app startup performance if it isn't used initially. 155 | 156 | --- 157 | 158 | Next: [Limitations](./limitations.md) 159 | 160 | Previous: [What is it for](./what-is-it-for.md) 161 | 162 | [Back to overview](./README.md) 163 | -------------------------------------------------------------------------------- /docs/limitations.md: -------------------------------------------------------------------------------- 1 | # Limitations 2 | 3 | ## Don't use classes that look like Tailwind classes but apply different styles 4 | 5 | tailwind-merge applies some heuristics to detect the type of a class even if that particular class does not exist in the default Tailwind config. E.g. the class `text-1000xl` does not exist in Tailwind CSS by default but is treated like a `font-size` class in tailwind-merge because it starts with `text-` followed by an optional number and a T-shirt size, like all the other `font-size` classes. 6 | 7 | This behavior has the advantage that you're less likely to need to configure tailwind-merge if you're only changing or extending some scales in your Tailwind config. But it also means that tailwind-merge treats classes that look like Tailwind classes as Tailwind classes although they might not be defined in your Tailwind config. 8 | 9 | ## You need to use label in arbitrary `background-position` and `background-size` classes 10 | 11 | tailwind-merge detects the type of class by parsing the class name. When using a class like `bg-[30%_30%]`, tailwind-merge can't know whether the class is a `background-position` or `background-size` class. 12 | 13 | Therefore it is necessary to always prepend arbitrary values of `background-position` classes with the label `position:` as in `bg-[position:30%_30%]`. The same applies to `background-size` classes which need to be prepended with `length:`, `size:` or `percentage:`. 14 | 15 | --- 16 | 17 | Next: [Configuration](./configuration.md) 18 | 19 | Previous: [Features](./features.md) 20 | 21 | [Back to overview](./README.md) 22 | -------------------------------------------------------------------------------- /docs/recipes.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | How to configure tailwind-merge with some common patterns. 4 | 5 | ## Adding custom scale from Tailwind config to tailwind-merge config 6 | 7 | > I have a custom shadow scale with the keys 100, 200 and 300 configured in Tailwind. How do I make tailwind-merge resolve conflicts among those? 8 | 9 | We'll be able to do this by creating a custom `twMerge` function with [`extendTailwindMerge`](./api-reference.md#extendtailwindmerge). 10 | 11 | First, we need to know whether we want to override or extend the default scale. Let's say we extended the default config by adding the CSS variable `--shadow-100`, `--shadow-200` and `--shadow-300` into the `@theme` layer, meaning that the default variables like `--shadow-sm` stay the same. 12 | 13 | Then we check whether our particular theme scale is included in tailwind-merge's theme config object [here](./configuration.md#theme). Because tailwind-merge supports Tailwind's `shadow` theme scale, we can add it to the tailwind-merge config like this: 14 | 15 | ```js 16 | import { extendTailwindMerge } from 'tailwind-merge' 17 | 18 | const twMerge = extendTailwindMerge({ 19 | extend: { 20 | theme: { 21 | // We only need to define the custom scale values without the `shadow-` prefix when adding them to the theme object 22 | shadow: ['100', '200', '300'], 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | In the hypothetical case of the `shadow` theme scale not being supported in tailwind-merge, we would need to check out the [default config of tailwind-merge](../src/lib/default-config.ts) and search for the class group ID of the box shadow scale. After a quick search we would find that tailwind-merge is using the key `shadow` for that group. We could add our custom classes to that group like this: 29 | 30 | ```js 31 | import { extendTailwindMerge } from 'tailwind-merge' 32 | 33 | const twMerge = extendTailwindMerge({ 34 | extend: { 35 | classGroups: { 36 | // In class groups we always need to define the entire class name like `shadow-100`, `shadow-200` and `shadow-300` 37 | // `{ shadow: ['100', '200', '300'] }` is a short-hand syntax for `'shadow-100', 'shadow-200', 'shadow-300'` 38 | shadow: [{ shadow: ['100', '200', '300'] }], 39 | }, 40 | }, 41 | }) 42 | ``` 43 | 44 | Note that by using the `extend` object we're only adding our custom classes to the existing ones in the config, so `twMerge('shadow-200 shadow-lg')` will return the string `shadow-lg`. If we want to override the class instead, we need to use the `override` object instead. 45 | 46 | ## Extracting classes with Tailwind's [`@apply`](https://tailwindcss.com/docs/reusing-styles#extracting-classes-with-apply) 47 | 48 | > How do I make tailwind-merge resolve conflicts with a custom class created with `@apply`? 49 | > 50 | > ```css 51 | > .btn-primary { 52 | > @apply py-2 px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-700; 53 | > } 54 | > ``` 55 | 56 | I don't recommend using Tailwind's `@apply` directive for classes that might get processed with tailwind-merge. 57 | 58 | tailwind-merge would need to be configured so that it knows about which classes `.btn-primary` is in conflict with. This means: If someone adds another Tailwind class to the `@apply` directive, the tailwind-merge config would need to get modified accordingly, keeping it in sync with the written CSS. This easy-to-miss dependency is fragile and can lead to bugs with incorrect merging behavior. 59 | 60 | Instead of creating custom CSS classes, I recommend keeping the collection of Tailwind classes in a string variable in JavaScript and access it whenever you want to apply those styles. This way you can reuse the collection of styles but don't need to touch the tailwind-merge config. 61 | 62 | ```jsx 63 | // React components with JSX syntax used in this example 64 | 65 | import { twMerge } from 'tailwind-merge' 66 | 67 | const BTN_PRIMARY_CLASSNAMES = 'py-2 px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-700' 68 | 69 | function ButtonPrimary(props) { 70 | return