├── .gitignore ├── .github ├── dependabot.yml ├── tailwindcss-mark.svg └── workflows │ └── nodejs.yml ├── .editorconfig ├── package.json ├── LICENSE ├── src ├── filters.js ├── index.js └── index.test.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | coverage 3 | node_modules 4 | npm-debug.log 5 | yarn-error.log 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/tailwindcss-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18, 20, 22] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm ci 26 | - run: npm test 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwindcss-preset-email", 3 | "version": "1.4.1", 4 | "description": "Tailwind CSS config preset for HTML emails", 5 | "license": "MIT", 6 | "repository": "https://github.com/maizzle/tailwindcss-preset-email", 7 | "main": "src/index.js", 8 | "files": [ 9 | "src" 10 | ], 11 | "scripts": { 12 | "dev": "vitest", 13 | "release": "npx np", 14 | "test": "vitest run --coverage" 15 | }, 16 | "devDependencies": { 17 | "@vitest/coverage-v8": "^3.0.5", 18 | "postcss": "^8.4.49", 19 | "vitest": "^3.0.4" 20 | }, 21 | "dependencies": { 22 | "tailwindcss-email-variants": "^3.0.3", 23 | "tailwindcss-mso": "^2.0.1" 24 | }, 25 | "peerDependencies": { 26 | "tailwindcss": ">=3.4.17" 27 | }, 28 | "keywords": [ 29 | "email-css", 30 | "email-styles", 31 | "html-email", 32 | "responsive-email", 33 | "maizzle", 34 | "tailwindcss" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Cosmin Popovici 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 | -------------------------------------------------------------------------------- /src/filters.js: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin') 2 | 3 | module.exports = { 4 | disabledFilterPlugins: { 5 | blur: false, 6 | brightness: false, 7 | contrast: false, 8 | dropShadow: false, 9 | grayscale: false, 10 | hueRotate: false, 11 | invert: false, 12 | saturate: false, 13 | sepia: false, 14 | backdropBlur: false, 15 | backdropBrightness: false, 16 | backdropContrast: false, 17 | backdropGrayscale: false, 18 | backdropHueRotate: false, 19 | backdropInvert: false, 20 | backdropOpacity: false, 21 | backdropSaturate: false, 22 | backdropSepia: false, 23 | }, 24 | blur: plugin(function({ matchUtilities, theme }) { 25 | matchUtilities( 26 | { 27 | blur: (value) => ({ 28 | filter: `blur(${value})` 29 | }), 30 | }, 31 | { values: theme('blur', ) } 32 | ) 33 | }), 34 | brightness: plugin(function({ matchUtilities, theme }) { 35 | matchUtilities( 36 | { 37 | brightness: (value) => ({ 38 | filter: `brightness(${value})` 39 | }), 40 | }, 41 | { values: theme('brightness') } 42 | ) 43 | }), 44 | contrast: plugin(function({ matchUtilities, theme }) { 45 | matchUtilities( 46 | { 47 | contrast: (value) => ({ 48 | filter: `contrast(${value})` 49 | }), 50 | }, 51 | { values: theme('contrast') } 52 | ) 53 | }), 54 | dropShadow: plugin(function({ matchUtilities, theme }) { 55 | matchUtilities( 56 | { 57 | 'drop-shadow': (value) => ({ 58 | filter: Array.isArray(value) 59 | ? value.map((v) => `drop-shadow(${v})`).join(' ') 60 | : `drop-shadow(${value})` 61 | }), 62 | }, 63 | { values: theme('dropShadow') } 64 | ) 65 | }), 66 | grayscale: plugin(function({ matchUtilities, theme }) { 67 | matchUtilities( 68 | { 69 | grayscale: (value) => ({ 70 | filter: `grayscale(${value})` 71 | }), 72 | }, 73 | { values: theme('grayscale') } 74 | ) 75 | }), 76 | hueRotate: plugin(function({ matchUtilities, theme }) { 77 | matchUtilities( 78 | { 79 | 'hue-rotate': (value) => ({ 80 | filter: `hue-rotate(${value})` 81 | }), 82 | }, 83 | { values: theme('hueRotate'), supportsNegativeValues: true } 84 | ) 85 | }), 86 | invert: plugin(function({ matchUtilities, theme }) { 87 | matchUtilities( 88 | { 89 | invert: (value) => ({ 90 | filter: `invert(${value})` 91 | }), 92 | }, 93 | { values: theme('invert') } 94 | ) 95 | }), 96 | saturate: plugin(function({ matchUtilities, theme }) { 97 | matchUtilities( 98 | { 99 | saturate: (value) => ({ 100 | filter: `saturate(${value})` 101 | }), 102 | }, 103 | { values: theme('saturate') } 104 | ) 105 | }), 106 | sepia: plugin(function({ matchUtilities, theme }) { 107 | matchUtilities( 108 | { 109 | sepia: (value) => ({ 110 | filter: `sepia(${value})` 111 | }), 112 | }, 113 | { values: theme('sepia') } 114 | ) 115 | }), 116 | backdropBlur: plugin(function({ matchUtilities, theme }) { 117 | matchUtilities( 118 | { 119 | 'backdrop-blur': (value) => ({ 120 | backdropFilter: `blur(${value})` 121 | }), 122 | }, 123 | { values: theme('backdropBlur') } 124 | ) 125 | }), 126 | backdropBrightness: plugin(function({ matchUtilities, theme }) { 127 | matchUtilities( 128 | { 129 | 'backdrop-brightness': (value) => ({ 130 | backdropFilter: `brightness(${value})` 131 | }), 132 | }, 133 | { values: theme('backdropBrightness') } 134 | ) 135 | }), 136 | backdropContrast: plugin(function({ matchUtilities, theme }) { 137 | matchUtilities( 138 | { 139 | 'backdrop-contrast': (value) => ({ 140 | backdropFilter: `contrast(${value})` 141 | }), 142 | }, 143 | { values: theme('backdropContrast') } 144 | ) 145 | }), 146 | backdropGrayscale: plugin(function({ matchUtilities, theme }) { 147 | matchUtilities( 148 | { 149 | 'backdrop-grayscale': (value) => ({ 150 | backdropFilter: `grayscale(${value})` 151 | }), 152 | }, 153 | { values: theme('backdropGrayscale') } 154 | ) 155 | }), 156 | backdropHueRotate: plugin(function({ matchUtilities, theme }) { 157 | matchUtilities( 158 | { 159 | 'backdrop-hue-rotate': (value) => ({ 160 | backdropFilter: `hue-rotate(${value})` 161 | }), 162 | }, 163 | { values: theme('backdropHueRotate'), supportsNegativeValues: true } 164 | ) 165 | }), 166 | backdropInvert: plugin(function({ matchUtilities, theme }) { 167 | matchUtilities( 168 | { 169 | 'backdrop-invert': (value) => ({ 170 | backdropFilter: `invert(${value})` 171 | }), 172 | }, 173 | { values: theme('backdropInvert') } 174 | ) 175 | }), 176 | backdropOpacity: plugin(function({ matchUtilities, theme }) { 177 | matchUtilities( 178 | { 179 | 'backdrop-opacity': (value) => ({ 180 | backdropFilter: `opacity(${value})` 181 | }), 182 | }, 183 | { values: theme('backdropOpacity') } 184 | ) 185 | }), 186 | backdropSaturate: plugin(function({ matchUtilities, theme }) { 187 | matchUtilities( 188 | { 189 | 'backdrop-saturate': (value) => ({ 190 | backdropFilter: `saturate(${value})` 191 | }), 192 | }, 193 | { values: theme('backdropSaturate') } 194 | ) 195 | }), 196 | backdropSepia: plugin(function({ matchUtilities, theme }) { 197 | matchUtilities( 198 | { 199 | 'backdrop-sepia': (value) => ({ 200 | backdropFilter: `sepia(${value})` 201 | }), 202 | }, 203 | { values: theme('backdropSepia') } 204 | ) 205 | }), 206 | } 207 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin') 2 | const { disabledFilterPlugins, ...filterPlugins } = require('./filters') 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | important: true, 7 | theme: { 8 | screens: { 9 | sm: {max: '600px'}, 10 | xs: {max: '430px'}, 11 | }, 12 | extend: { 13 | colors: { 14 | black: '#000001', 15 | white: '#fffffe', 16 | }, 17 | spacing: { 18 | screen: '100vw', 19 | full: '100%', 20 | 0: '0', 21 | 0.5: '2px', 22 | 1: '4px', 23 | 1.5: '6px', 24 | 2: '8px', 25 | 2.5: '10px', 26 | 3: '12px', 27 | 3.5: '14px', 28 | 4: '16px', 29 | 4.5: '18px', 30 | 5: '20px', 31 | 5.5: '22px', 32 | 6: '24px', 33 | 6.5: '26px', 34 | 7: '28px', 35 | 7.5: '30px', 36 | 8: '32px', 37 | 8.5: '34px', 38 | 9: '36px', 39 | 9.5: '38px', 40 | 10: '40px', 41 | 11: '44px', 42 | 12: '48px', 43 | 14: '56px', 44 | 16: '64px', 45 | 18: '72px', 46 | 20: '80px', 47 | 22: '88px', 48 | 24: '96px', 49 | 26: '104px', 50 | 28: '112px', 51 | 30: '120px', 52 | 32: '128px', 53 | 34: '136px', 54 | 36: '144px', 55 | 38: '152px', 56 | 40: '160px', 57 | 42: '168px', 58 | 44: '176px', 59 | 46: '184px', 60 | 48: '192px', 61 | 50: '200px', 62 | 52: '208px', 63 | 54: '216px', 64 | 56: '224px', 65 | 58: '232px', 66 | 60: '240px', 67 | 62: '248px', 68 | 64: '256px', 69 | 66: '264px', 70 | 68: '272px', 71 | 70: '280px', 72 | 72: '288px', 73 | 74: '296px', 74 | 76: '304px', 75 | 78: '312px', 76 | 80: '320px', 77 | 82: '328px', 78 | 84: '336px', 79 | 86: '344px', 80 | 88: '352px', 81 | 90: '360px', 82 | 92: '368px', 83 | 94: '376px', 84 | 96: '384px', 85 | }, 86 | borderRadius: { 87 | none: '0px', 88 | sm: '2px', 89 | DEFAULT: '4px', 90 | md: '6px', 91 | lg: '8px', 92 | xl: '12px', 93 | '2xl': '16px', 94 | '3xl': '24px', 95 | }, 96 | boxShadow: { 97 | sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 98 | DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)', 99 | md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', 100 | lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', 101 | xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', 102 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 103 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)', 104 | }, 105 | dropShadow: { 106 | sm: '0 1px 1px rgba(0, 0, 0, 0.05)', 107 | DEFAULT: [ 108 | '0 1px 2px rgba(0, 0, 0, 0.1)', 109 | '0 1px 1px rgba(0, 0, 0, 0.06)', 110 | ], 111 | md: [ 112 | '0 4px 3px rgba(0, 0, 0, 0.07)', 113 | '0 2px 2px rgba(0, 0, 0, 0.06)', 114 | ], 115 | lg: [ 116 | '0 10px 8px rgba(0, 0, 0, 0.04)', 117 | '0 4px 3px rgba(0, 0, 0, 0.1)', 118 | ], 119 | xl: [ 120 | '0 20px 13px rgba(0, 0, 0, 0.03)', 121 | '0 8px 5px rgba(0, 0, 0, 0.08)', 122 | ], 123 | '2xl': '0 25px 25px rgba(0, 0, 0, 0.15)', 124 | none: '0 0 #000', 125 | }, 126 | fontFamily: { 127 | inter: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], 128 | sans: ['ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], 129 | serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], 130 | mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'], 131 | }, 132 | fontSize: { 133 | 0: '0', 134 | xxs: '11px', 135 | xs: '12px', 136 | '2xs': '13px', 137 | sm: '14px', 138 | '2sm': '15px', 139 | base: '16px', 140 | lg: '18px', 141 | xl: '20px', 142 | '2xl': '24px', 143 | '3xl': '30px', 144 | '4xl': '36px', 145 | '5xl': '48px', 146 | '6xl': '60px', 147 | '7xl': '72px', 148 | '8xl': '96px', 149 | '9xl': '128px', 150 | }, 151 | letterSpacing: theme => ({ 152 | ...theme('width'), 153 | }), 154 | lineHeight: theme => ({ 155 | ...theme('width'), 156 | }), 157 | maxWidth: theme => ({ 158 | ...theme('width'), 159 | xs: '160px', 160 | sm: '192px', 161 | md: '224px', 162 | lg: '256px', 163 | xl: '288px', 164 | '2xl': '336px', 165 | '3xl': '384px', 166 | '4xl': '448px', 167 | '5xl': '512px', 168 | '6xl': '576px', 169 | '7xl': '640px', 170 | }), 171 | minHeight: theme => ({ 172 | ...theme('width'), 173 | }), 174 | minWidth: theme => ({ 175 | ...theme('width'), 176 | }), 177 | }, 178 | }, 179 | // We disable all core plugins that we redefine 180 | corePlugins: { 181 | preflight: false, 182 | backgroundOpacity: false, 183 | borderOpacity: false, 184 | borderSpacing: false, 185 | boxShadow: false, 186 | boxShadowColor: false, 187 | divideOpacity: false, 188 | placeholderOpacity: false, 189 | textOpacity: false, 190 | textDecoration: false, 191 | ...disabledFilterPlugins, 192 | }, 193 | plugins: [ 194 | plugin(function({ matchUtilities, addUtilities, theme }) { 195 | // Border-spacing utilities 196 | matchUtilities( 197 | { 198 | 'border-spacing': (value) => ({ 199 | 'border-spacing': value, 200 | }), 201 | 'border-spacing-y': (value) => ({ 202 | 'border-spacing': `0 ${value}`, 203 | }), 204 | 'border-spacing-x': (value) => ({ 205 | 'border-spacing': `${value} 0`, 206 | }), 207 | }, 208 | { values: theme('borderSpacing', ) } 209 | ) 210 | 211 | // Box-shadow utilities 212 | matchUtilities( 213 | { 214 | shadow: value => ({ 215 | boxShadow: value 216 | }), 217 | }, 218 | { 219 | values: theme('boxShadow') 220 | } 221 | ) 222 | 223 | // Text decoration utilities 224 | addUtilities({ 225 | '.underline': { 'text-decoration': 'underline' }, 226 | '.overline': { 'text-decoration': 'overline' }, 227 | '.line-through': { 'text-decoration': 'line-through' }, 228 | '.no-underline': { 'text-decoration': 'none' }, 229 | }) 230 | }), 231 | // Filters 232 | ...Object.values(filterPlugins), 233 | // MSO utilities 234 | require('tailwindcss-mso'), 235 | // Email client targeting variants 236 | require('tailwindcss-email-variants'), 237 | ], 238 | } 239 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Tailwind CSS 3 |

Tailwind CSS Email Preset

4 |

Tailwind CSS config preset for HTML emails

5 |
6 | 7 | ## About 8 | 9 | This is a Tailwind CSS v3.x config preset that changes some utility classes to use values that are better supported in email clients. It also includes plugins that generate utility classes that are useful for building HTML emails. 10 | 11 | For Tailwind CSS v4.x, see [maizzle/tailwindcss](https://github.com/maizzle/tailwindcss). 12 | 13 | ## Installation 14 | 15 | ```bash 16 | npm install tailwindcss-preset-email 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```js 22 | // tailwind.config.js 23 | module.exports = { 24 | presets: [ 25 | require('tailwindcss-preset-email'), 26 | ], 27 | } 28 | ``` 29 | 30 | ### Customization 31 | 32 | You may override the preset by configuring Tailwind as you'd normally do. 33 | 34 | ```js 35 | // tailwind.config.js 36 | module.exports = { 37 | presets: [ 38 | require('tailwindcss-preset-email'), 39 | ], 40 | theme: { 41 | screens: { 42 | sm: '640px', 43 | md: '768px', 44 | }, 45 | }, 46 | } 47 | ``` 48 | 49 | ### Configuration 50 | 51 | The preset is a custom Tailwind CSS config that changes utility classes to use values that are better supported in email clients, either right in the config or through plugins. 52 | 53 | ### important 54 | 55 | The `important` key is set to `true`. 56 | 57 | HTML emails often use inline CSS, so this ensures that any CSS generated by Tailwind uses `!important` to override inline styles (unless the inline styles themselves use `!important`). 58 | 59 | ### screens 60 | 61 | The `screens` config uses a desktop-first approach now: 62 | 63 | ```js 64 | { 65 | sm: {max: '600px'}, 66 | xs: {max: '430px'}, 67 | } 68 | ``` 69 | 70 | When overriding `screens`, make sure the larger values are at the top of the object: 71 | 72 | ```js 73 | { 74 | md: {max: '768px'}, 75 | sm: {max: '600px'}, 76 | xs: {max: '430px'}, 77 | } 78 | ``` 79 | 80 | ### colors 81 | 82 | The `black` and `white` color values are updated to prevent some email clients from automatically inverting them in dark mode. 83 | 84 | ```js 85 | colors: { 86 | black: '#000001', 87 | white: '#fffffe', 88 | } 89 | ``` 90 | 91 | ### spacing 92 | 93 | The `spacing` scale has been updated to use `px` values instead of `rem`. 94 | 95 | ```js 96 | spacing: { 97 | screen: '100vw', 98 | full: '100%', 99 | 0: '0', 100 | 0.5: '2px', 101 | 1: '4px', 102 | 1.5: '6px', 103 | 2: '8px', 104 | // ... 105 | } 106 | ``` 107 | 108 | ### borderRadius 109 | 110 | The `borderRadius` scale has been updated to use `px` values instead of `rem`. 111 | 112 | ```js 113 | borderRadius: { 114 | none: '0px', 115 | sm: '2px', 116 | DEFAULT: '4px', 117 | md: '6px', 118 | lg: '8px', 119 | xl: '12px', 120 | '2xl': '16px', 121 | '3xl': '24px', 122 | } 123 | ``` 124 | 125 | ### boxShadow 126 | 127 | `boxShadow` utilities use the exact values from your config. 128 | 129 | ```js 130 | boxShadow: { 131 | sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 132 | DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)', 133 | md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', 134 | lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', 135 | xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', 136 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 137 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)', 138 | } 139 | ``` 140 | 141 | ### dropShadow 142 | 143 | Although not that much used in HTML email, the `dropShadow` utilities are now generated using the exact values from your config. 144 | 145 | It defaults to this: 146 | 147 | ```js 148 | dropShadow: { 149 | sm: '0 1px 1px rgba(0, 0, 0, 0.05)', 150 | DEFAULT: [ 151 | '0 1px 2px rgba(0, 0, 0, 0.1)', 152 | '0 1px 1px rgba(0, 0, 0, 0.06)', 153 | ], 154 | md: [ 155 | '0 4px 3px rgba(0, 0, 0, 0.07)', 156 | '0 2px 2px rgba(0, 0, 0, 0.06)', 157 | ], 158 | lg: [ 159 | '0 10px 8px rgba(0, 0, 0, 0.04)', 160 | '0 4px 3px rgba(0, 0, 0, 0.1)', 161 | ], 162 | xl: [ 163 | '0 20px 13px rgba(0, 0, 0, 0.03)', 164 | '0 8px 5px rgba(0, 0, 0, 0.08)', 165 | ], 166 | '2xl': '0 25px 25px rgba(0, 0, 0, 0.15)', 167 | none: '0 0 #000', 168 | } 169 | ``` 170 | 171 | ### fontFamily 172 | 173 | `fontFamily` font stacks have been simplified to use web-safe fonts only. 174 | 175 | ```js 176 | fontFamily: { 177 | sans: ['ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], 178 | serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], 179 | mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'], 180 | } 181 | ``` 182 | 183 | ### fontSize 184 | 185 | `fontSize` values have been updated to use `px` values instead of `rem`. 186 | 187 | ```js 188 | fontSize: { 189 | 0: '0', 190 | xxs: '11px', 191 | xs: '12px', 192 | '2xs': '13px', 193 | sm: '14px', 194 | '2sm': '15px', 195 | base: '16px', 196 | lg: '18px', 197 | xl: '20px', 198 | '2xl': '24px', 199 | '3xl': '30px', 200 | '4xl': '36px', 201 | '5xl': '48px', 202 | '6xl': '60px', 203 | '7xl': '72px', 204 | '8xl': '96px', 205 | '9xl': '128px', 206 | } 207 | ``` 208 | 209 | ### letterSpacing 210 | 211 | `letterSpacing` values have been updated to use values from your `width` scale. 212 | 213 | ```js 214 | letterSpacing: theme => ({ 215 | ...theme('width'), 216 | }) 217 | ``` 218 | 219 | ### lineHeight 220 | 221 | Likewise, `lineHeight` values have been updated to use values from your `width` scale. 222 | 223 | ```js 224 | lineHeight: theme => ({ 225 | ...theme('width'), 226 | }) 227 | ``` 228 | 229 | ### maxWidth 230 | 231 | `maxWidth` values have been updated to use `px` values instead of `rem`, and now also use values from your `width` scale. 232 | 233 | ```js 234 | maxWidth: theme => ({ 235 | ...theme('width'), 236 | xs: '160px', 237 | sm: '192px', 238 | md: '224px', 239 | lg: '256px', 240 | xl: '288px', 241 | '2xl': '336px', 242 | '3xl': '384px', 243 | '4xl': '448px', 244 | '5xl': '512px', 245 | '6xl': '576px', 246 | '7xl': '640px', 247 | }) 248 | ``` 249 | 250 | ### minHeight 251 | 252 | `minHeight` values have been updated to use values from your `width` scale. 253 | 254 | ```js 255 | minHeight: theme => ({ 256 | ...theme('width'), 257 | }) 258 | ``` 259 | 260 | ### minWidth 261 | 262 | `minWidth` values have been updated to use values from your `width` scale. 263 | 264 | ```js 265 | minWidth: theme => ({ 266 | ...theme('width'), 267 | }) 268 | ``` 269 | 270 | ## Plugins 271 | 272 | The preset includes the following plugins: 273 | 274 | ### tailwindcss-mso 275 | 276 | Used for generating classes that are only supported by Microsoft Outlook's Word rendering engine, for Outlook on Windows (versions 2007 and up). 277 | 278 | Documentation: https://github.com/maizzle/tailwindcss-mso 279 | 280 | ### tailwindcss-email-variants 281 | 282 | A Tailwind CSS plugin that provides variants for email client targeting hacks used in HTML emails. 283 | 284 | Documentation: https://github.com/maizzle/tailwindcss-email-variants 285 | 286 | ### borderSpacing 287 | 288 | A custom plugin that generates `border-spacing` utilities that use static values instead of CSS variables. 289 | 290 | Here's a diff of the output between Tailwind's original and the custom plugin: 291 | 292 | ```diff 293 | - .border-spacing-x-1 { 294 | - --tw-border-spacing-x: 4px; 295 | - border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y); 296 | - } 297 | + .border-spacing-x-1 { 298 | + border-spacing: 4px 0 299 | + } 300 | ``` 301 | 302 | ### boxShadow 303 | 304 | A custom plugin that generates `box-shadow` utilities that use static values instead of CSS variables. 305 | 306 | The downside to this is that shadow color utilities are disabled too. 307 | 308 | ```diff 309 | - .shadow-sm { 310 | - --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 311 | - --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 312 | - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 313 | - } 314 | + .shadow-sm { 315 | + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) 316 | + } 317 | ``` 318 | 319 | ### Filters 320 | 321 | The following plugins that generate utilities for CSS filters have been updated to use static values instead of CSS variables: 322 | 323 | - blur 324 | - brightness 325 | - contrast 326 | - dropShadow 327 | - grayscale 328 | - hueRotate 329 | - invert 330 | - saturate 331 | - sepia 332 | - backdropBlur 333 | - backdropBrightness 334 | - backdropContrast 335 | - backdropGrayscale 336 | - backdropHueRotate 337 | - backdropInvert 338 | - backdropOpacity 339 | - backdropSaturate 340 | - backdropSepia 341 | 342 | ### textDecoration 343 | 344 | A custom plugin that generates `text-decoration` utilities that use the `text-decoration` property instead of `text-decoration-line`, which has poor support in email clients. 345 | 346 | Here's a diff of the output between Tailwind's original and the custom plugin: 347 | 348 | ```diff 349 | .underline { 350 | - text-decoration-line: underline; 351 | + text-decoration: underline 352 | } 353 | ``` 354 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import postcss from 'postcss' 3 | import emailPreset from '.' 4 | import { describe, expect, test } from 'vitest' 5 | import tailwindcss from 'tailwindcss' 6 | 7 | // Custom CSS matcher 8 | expect.extend({ 9 | // Compare two CSS strings with all whitespace removed 10 | // This is probably naive but it's fast and works well enough. 11 | toMatchCss(received, argument) { 12 | function stripped(string_) { 13 | return string_ 14 | .replaceAll(/\s/g, '') 15 | .replaceAll(';', '') 16 | } 17 | 18 | const pass = stripped(received) === stripped(argument) 19 | 20 | return { 21 | pass, 22 | actual: received, 23 | expected: argument, 24 | message: () => pass ? 'All good!' : 'CSS does not match', 25 | } 26 | } 27 | }) 28 | 29 | // Function to run the plugin 30 | function run(config, css = '@tailwind utilities', plugin = tailwindcss) { 31 | let { currentTestName } = expect.getState() 32 | 33 | config = { 34 | ...{ 35 | presets: [emailPreset], 36 | important: false, 37 | }, 38 | ...config, 39 | } 40 | 41 | return postcss(plugin(config)).process(css, { 42 | from: `${path.resolve(__filename)}?test=${currentTestName}`, 43 | }) 44 | } 45 | 46 | test('borderSpacing', () => { 47 | const config = { 48 | content: [ 49 | { 50 | raw: String.raw` 51 |
52 |
53 |
54 | ` 55 | } 56 | ], 57 | } 58 | 59 | return run(config).then(result => { 60 | expect(result.css).toMatchCss(String.raw` 61 | .border-spacing-0 { 62 | border-spacing: 0 63 | } 64 | .border-spacing-x-1 { 65 | border-spacing: 4px 0 66 | } 67 | .border-spacing-y-1 { 68 | border-spacing: 0 4px 69 | } 70 | `) 71 | }) 72 | }) 73 | 74 | test('boxShadow', () => { 75 | const config = { 76 | content: [ 77 | { 78 | raw: String.raw` 79 |
80 | ` 81 | } 82 | ], 83 | } 84 | 85 | return run(config).then(result => { 86 | expect(result.css).toMatchCss(String.raw` 87 | .shadow { 88 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1) 89 | } 90 | `) 91 | }) 92 | }) 93 | 94 | test('textDecoration', () => { 95 | const config = { 96 | content: [ 97 | { 98 | raw: String.raw` 99 |
100 |
101 |
102 |
103 | ` 104 | } 105 | ], 106 | } 107 | 108 | return run(config).then(result => { 109 | expect(result.css).toMatchCss(String.raw` 110 | .underline { 111 | text-decoration: underline 112 | } 113 | .overline { 114 | text-decoration: overline 115 | } 116 | .line-through { 117 | text-decoration: line-through 118 | } 119 | .no-underline { 120 | text-decoration: none 121 | } 122 | `) 123 | }) 124 | }) 125 | 126 | describe('Filters', () => { 127 | test('blur', () => { 128 | const config = { 129 | content: [ 130 | { 131 | raw: String.raw` 132 |
133 |
134 | ` 135 | } 136 | ], 137 | } 138 | 139 | return run(config).then(result => { 140 | expect(result.css).toMatchCss(String.raw` 141 | .blur-\[2px\] { 142 | filter: blur(2px) 143 | } 144 | .blur-xl { 145 | filter: blur(24px) 146 | } 147 | `) 148 | }) 149 | }) 150 | 151 | test('brightness', () => { 152 | const config = { 153 | content: [ 154 | { 155 | raw: String.raw` 156 |
157 |
158 | ` 159 | } 160 | ], 161 | } 162 | 163 | return run(config).then(result => { 164 | expect(result.css).toMatchCss(String.raw` 165 | .brightness-50 { 166 | filter: brightness(.5) 167 | } 168 | .brightness-\[\.33\] { 169 | filter: brightness(.33) 170 | } 171 | `) 172 | }) 173 | }) 174 | 175 | test('contrast', () => { 176 | const config = { 177 | content: [ 178 | { 179 | raw: String.raw` 180 |
181 |
182 | ` 183 | } 184 | ], 185 | } 186 | 187 | return run(config).then(result => { 188 | expect(result.css).toMatchCss(String.raw` 189 | .contrast-50 { 190 | filter: contrast(.5) 191 | } 192 | .contrast-\[\.33\] { 193 | filter: contrast(.33) 194 | } 195 | `) 196 | }) 197 | }) 198 | 199 | test('dropShadow', () => { 200 | const config = { 201 | content: [ 202 | { 203 | raw: String.raw` 204 |
205 |
206 |
207 | ` 208 | } 209 | ], 210 | } 211 | 212 | return run(config).then(result => { 213 | expect(result.css).toMatchCss(String.raw` 214 | .drop-shadow { 215 | filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.06)) 216 | } 217 | .drop-shadow-\[0_35px_35px_rgba\(0\2c 0\2c 0\2c 0\.25\)\] { 218 | filter: drop-shadow(0 35px 35px rgba(0,0,0,0.25)) 219 | } 220 | .drop-shadow-sm { 221 | filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)) 222 | } 223 | `) 224 | }) 225 | }) 226 | 227 | test('grayscale', () => { 228 | const config = { 229 | content: [ 230 | { 231 | raw: String.raw` 232 |
233 |
234 |
235 | ` 236 | } 237 | ], 238 | } 239 | 240 | return run(config).then(result => { 241 | expect(result.css).toMatchCss(String.raw` 242 | .grayscale { 243 | filter: grayscale(100%) 244 | } 245 | .grayscale-0 { 246 | filter: grayscale(0) 247 | } 248 | .grayscale-\[50\%\] { 249 | filter: grayscale(50%) 250 | } 251 | `) 252 | }) 253 | }) 254 | 255 | test('hueRotate', () => { 256 | const config = { 257 | content: [ 258 | { 259 | raw: String.raw` 260 |
261 |
262 |
263 | ` 264 | } 265 | ], 266 | } 267 | 268 | return run(config).then(result => { 269 | expect(result.css).toMatchCss(String.raw` 270 | .-hue-rotate-60 { 271 | filter: hue-rotate(-60deg) 272 | } 273 | .hue-rotate-180 { 274 | filter: hue-rotate(180deg) 275 | } 276 | .hue-rotate-\[90deg\] { 277 | filter: hue-rotate(90deg) 278 | } 279 | `) 280 | }) 281 | }) 282 | 283 | test('invert', () => { 284 | const config = { 285 | content: [ 286 | { 287 | raw: String.raw` 288 |
289 |
290 | ` 291 | } 292 | ], 293 | } 294 | 295 | return run(config).then(result => { 296 | expect(result.css).toMatchCss(String.raw` 297 | .invert { 298 | filter: invert(100%) 299 | } 300 | .invert-\[\.25\] { 301 | filter: invert(.25) 302 | } 303 | `) 304 | }) 305 | }) 306 | 307 | test('saturate', () => { 308 | const config = { 309 | content: [ 310 | { 311 | raw: String.raw` 312 |
313 |
314 | ` 315 | } 316 | ], 317 | } 318 | 319 | return run(config).then(result => { 320 | expect(result.css).toMatchCss(String.raw` 321 | .saturate-0 { 322 | filter: saturate(0) 323 | } 324 | .saturate-\[\.25\] { 325 | filter: saturate(.25) 326 | } 327 | `) 328 | }) 329 | }) 330 | 331 | test('sepia', () => { 332 | const config = { 333 | content: [ 334 | { 335 | raw: String.raw` 336 |
337 |
338 | ` 339 | } 340 | ], 341 | } 342 | 343 | return run(config).then(result => { 344 | expect(result.css).toMatchCss(String.raw` 345 | .sepia-0 { 346 | filter: sepia(0) 347 | } 348 | .sepia-\[\.25\] { 349 | filter: sepia(.25) 350 | } 351 | `) 352 | }) 353 | }) 354 | 355 | test('backdropBlur', () => { 356 | const config = { 357 | content: [ 358 | { 359 | raw: String.raw` 360 |
361 |
362 | ` 363 | } 364 | ], 365 | } 366 | 367 | return run(config).then(result => { 368 | expect(result.css).toMatchCss(String.raw` 369 | .backdrop-blur-\[2px\] { 370 | backdrop-filter: blur(2px) 371 | } 372 | .backdrop-blur-sm { 373 | backdrop-filter: blur(4px) 374 | } 375 | `) 376 | }) 377 | }) 378 | 379 | test('backdropBrightness', () => { 380 | const config = { 381 | content: [ 382 | { 383 | raw: String.raw` 384 |
385 |
386 | ` 387 | } 388 | ], 389 | } 390 | 391 | return run(config).then(result => { 392 | expect(result.css).toMatchCss(String.raw` 393 | .backdrop-brightness-50 { 394 | backdrop-filter: brightness(.5) 395 | } 396 | .backdrop-brightness-\[1\.75\] { 397 | backdrop-filter: brightness(1.75) 398 | } 399 | `) 400 | }) 401 | }) 402 | 403 | test('backdropContrast', () => { 404 | const config = { 405 | content: [ 406 | { 407 | raw: String.raw` 408 |
409 |
410 | ` 411 | } 412 | ], 413 | } 414 | 415 | return run(config).then(result => { 416 | expect(result.css).toMatchCss(String.raw` 417 | .backdrop-contrast-50 { 418 | backdrop-filter: contrast(.5) 419 | } 420 | .backdrop-contrast-\[1\.75\] { 421 | backdrop-filter: contrast(1.75) 422 | } 423 | `) 424 | }) 425 | }) 426 | 427 | test('backdropGrayscale', () => { 428 | const config = { 429 | content: [ 430 | { 431 | raw: String.raw` 432 |
433 |
434 | ` 435 | } 436 | ], 437 | } 438 | 439 | return run(config).then(result => { 440 | expect(result.css).toMatchCss(String.raw` 441 | .backdrop-grayscale { 442 | backdrop-filter: grayscale(100%) 443 | } 444 | .backdrop-grayscale-\[\.5\] { 445 | backdrop-filter: grayscale(.5) 446 | } 447 | `) 448 | }) 449 | }) 450 | 451 | test('backdropHueRotate', () => { 452 | const config = { 453 | content: [ 454 | { 455 | raw: String.raw` 456 |
457 |
458 |
459 | ` 460 | } 461 | ], 462 | } 463 | 464 | return run(config).then(result => { 465 | expect(result.css).toMatchCss(String.raw` 466 | .-backdrop-hue-rotate-15 { 467 | backdrop-filter: hue-rotate(-15deg) 468 | } 469 | .backdrop-hue-rotate-15 { 470 | backdrop-filter: hue-rotate(15deg) 471 | } 472 | .backdrop-hue-rotate-\[35deg\] { 473 | backdrop-filter: hue-rotate(35deg) 474 | } 475 | `) 476 | }) 477 | }) 478 | 479 | test('backdropInvert', () => { 480 | const config = { 481 | content: [ 482 | { 483 | raw: String.raw` 484 |
485 |
486 | ` 487 | } 488 | ], 489 | } 490 | 491 | return run(config).then(result => { 492 | expect(result.css).toMatchCss(String.raw` 493 | .backdrop-invert { 494 | backdrop-filter: invert(100%) 495 | } 496 | .backdrop-invert-\[\.25\] { 497 | backdrop-filter: invert(.25) 498 | } 499 | `) 500 | }) 501 | }) 502 | 503 | test('backdropOpacity', () => { 504 | const config = { 505 | content: [ 506 | { 507 | raw: String.raw` 508 |
509 |
510 | ` 511 | } 512 | ], 513 | } 514 | 515 | return run(config).then(result => { 516 | expect(result.css).toMatchCss(String.raw` 517 | .backdrop-opacity-5 { 518 | backdrop-filter: opacity(0.05) 519 | } 520 | .backdrop-opacity-\[\.25\] { 521 | backdrop-filter: opacity(.25) 522 | } 523 | `) 524 | }) 525 | }) 526 | 527 | test('backdropSaturate', () => { 528 | const config = { 529 | content: [ 530 | { 531 | raw: String.raw` 532 |
533 |
534 | ` 535 | } 536 | ], 537 | } 538 | 539 | return run(config).then(result => { 540 | expect(result.css).toMatchCss(String.raw` 541 | .backdrop-saturate-50 { 542 | backdrop-filter: saturate(.5) 543 | } 544 | .backdrop-saturate-\[\.25\] { 545 | backdrop-filter: saturate(.25) 546 | } 547 | `) 548 | }) 549 | }) 550 | 551 | test('backdropSepia', () => { 552 | const config = { 553 | content: [ 554 | { 555 | raw: String.raw` 556 |
557 |
558 | ` 559 | } 560 | ], 561 | } 562 | 563 | return run(config).then(result => { 564 | expect(result.css).toMatchCss(String.raw` 565 | .backdrop-sepia { 566 | backdrop-filter: sepia(100%) 567 | } 568 | .backdrop-sepia-\[\.25\] { 569 | backdrop-filter: sepia(.25) 570 | } 571 | `) 572 | }) 573 | }) 574 | }) 575 | --------------------------------------------------------------------------------